From 749cfb3f92358a6a581d0edf7544aaad66c9c788 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sun, 11 Jan 2026 22:35:15 +0700 Subject: [PATCH] feat: Page Editor Phase 1 - React DynamicPageRenderer - Add DynamicPageRenderer component for structural pages and CPT content - Add 6 section components: - HeroSection with multiple layout variants - ContentSection for rich text/HTML content - ImageTextSection with image-left/right layouts - FeatureGridSection with grid-2/3/4 layouts - CTABannerSection with color schemes - ContactFormSection with webhook POST and redirect - Add dynamic routes to App.tsx for /:slug and /:pathBase/:slug - Build customer-spa successfully --- customer-spa/src/App.tsx | 8 +- customer-spa/src/pages/DynamicPage/index.tsx | 194 ++++++++++++++++++ .../DynamicPage/sections/CTABannerSection.tsx | 74 +++++++ .../sections/ContactFormSection.tsx | 143 +++++++++++++ .../DynamicPage/sections/ContentSection.tsx | 46 +++++ .../sections/FeatureGridSection.tsx | 94 +++++++++ .../DynamicPage/sections/HeroSection.tsx | 116 +++++++++++ .../DynamicPage/sections/ImageTextSection.tsx | 75 +++++++ 8 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 customer-spa/src/pages/DynamicPage/index.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/CTABannerSection.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/ContactFormSection.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/ContentSection.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/FeatureGridSection.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/HeroSection.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/ImageTextSection.tsx diff --git a/customer-spa/src/App.tsx b/customer-spa/src/App.tsx index 468cfd2..f10f559 100644 --- a/customer-spa/src/App.tsx +++ b/customer-spa/src/App.tsx @@ -19,6 +19,7 @@ import Wishlist from './pages/Wishlist'; import Login from './pages/Login'; import ForgotPassword from './pages/ForgotPassword'; import ResetPassword from './pages/ResetPassword'; +import { DynamicPageRenderer } from './pages/DynamicPage'; // Create QueryClient instance const queryClient = new QueryClient({ @@ -92,8 +93,11 @@ function AppRoutes() { {/* My Account */} } /> - {/* Fallback to initial route */} - } /> + {/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */} + } /> + + {/* Dynamic Pages - Structural pages (e.g., /about, /contact) */} + } /> ); diff --git a/customer-spa/src/pages/DynamicPage/index.tsx b/customer-spa/src/pages/DynamicPage/index.tsx new file mode 100644 index 0000000..8d888aa --- /dev/null +++ b/customer-spa/src/pages/DynamicPage/index.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api/client'; +import { Helmet } from 'react-helmet-async'; + +// Section Components +import { HeroSection } from './sections/HeroSection'; +import { ContentSection } from './sections/ContentSection'; +import { ImageTextSection } from './sections/ImageTextSection'; +import { FeatureGridSection } from './sections/FeatureGridSection'; +import { CTABannerSection } from './sections/CTABannerSection'; +import { ContactFormSection } from './sections/ContactFormSection'; + +// Types +interface SectionProp { + type: 'static' | 'dynamic'; + value?: any; + source?: string; +} + +interface Section { + id: string; + type: string; + layoutVariant?: string; + colorScheme?: string; + props: Record; +} + +interface PageData { + id?: number; + type: 'page' | 'content'; + slug?: string; + title?: string; + url?: string; + seo?: { + meta_title?: string; + meta_description?: string; + canonical?: string; + og_title?: string; + og_description?: string; + og_image?: string; + }; + structure?: { + sections: Section[]; + }; + post?: Record; + rendered?: { + sections: Section[]; + }; +} + +// Section renderer map +const SECTION_COMPONENTS: Record> = { + hero: HeroSection, + content: ContentSection, + 'image-text': ImageTextSection, + 'image_text': ImageTextSection, + 'feature-grid': FeatureGridSection, + 'feature_grid': FeatureGridSection, + 'cta-banner': CTABannerSection, + 'cta_banner': CTABannerSection, + 'contact-form': ContactFormSection, + 'contact_form': ContactFormSection, +}; + +/** + * DynamicPageRenderer + * Renders structural pages and CPT template content + */ +export function DynamicPageRenderer() { + const { pathBase, slug } = useParams<{ pathBase?: string; slug?: string }>(); + const navigate = useNavigate(); + const [notFound, setNotFound] = useState(false); + + // Determine if this is a page or CPT content + const isStructuralPage = !pathBase; + const contentType = pathBase === 'blog' ? 'post' : pathBase; + const contentSlug = slug || ''; + + // Fetch page/content data + const { data: pageData, isLoading, error } = useQuery({ + queryKey: ['dynamic-page', pathBase, slug], + queryFn: async (): Promise => { + if (isStructuralPage) { + // Fetch structural page + const response = await api.get(`/pages/${slug}`); + return response.data; + } else { + // Fetch CPT content with template + const response = await api.get(`/content/${contentType}/${contentSlug}`); + return response.data; + } + }, + retry: false, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); + + // Handle 404 + useEffect(() => { + if (error) { + setNotFound(true); + } + }, [error]); + + // Get sections to render + const sections = isStructuralPage + ? pageData?.structure?.sections || [] + : pageData?.rendered?.sections || []; + + // Loading state + if (isLoading) { + return ( +
+
+
+ ); + } + + // 404 state + if (notFound || !pageData) { + return ( +
+

404

+

Page not found

+ +
+ ); + } + + return ( + <> + {/* SEO */} + + {pageData.seo?.meta_title || pageData.title || 'Page'} + {pageData.seo?.meta_description && ( + + )} + {pageData.seo?.canonical && ( + + )} + {pageData.seo?.og_title && ( + + )} + {pageData.seo?.og_description && ( + + )} + {pageData.seo?.og_image && ( + + )} + + + {/* Render sections */} +
+ {sections.map((section) => { + const SectionComponent = SECTION_COMPONENTS[section.type]; + + if (!SectionComponent) { + // Fallback for unknown section types + return ( +
+
+                                    Unknown section type: {section.type}
+                                
+
+ ); + } + + return ( + + ); + })} + + {/* Empty state */} + {sections.length === 0 && ( +
+

This page has no content yet.

+
+ )} +
+ + ); +} diff --git a/customer-spa/src/pages/DynamicPage/sections/CTABannerSection.tsx b/customer-spa/src/pages/DynamicPage/sections/CTABannerSection.tsx new file mode 100644 index 0000000..c257992 --- /dev/null +++ b/customer-spa/src/pages/DynamicPage/sections/CTABannerSection.tsx @@ -0,0 +1,74 @@ +import { cn } from '@/lib/utils'; + +interface CTABannerSectionProps { + id: string; + layout?: string; + colorScheme?: string; + title?: string; + text?: string; + button_text?: string; + button_url?: string; +} + +export function CTABannerSection({ + id, + layout = 'default', + colorScheme = 'primary', + title, + text, + button_text, + button_url, +}: CTABannerSectionProps) { + return ( +
+
+ {title && ( +

+ {title} +

+ )} + + {text && ( +

+ {text} +

+ )} + + {button_text && button_url && ( + + {button_text} + + )} +
+
+ ); +} diff --git a/customer-spa/src/pages/DynamicPage/sections/ContactFormSection.tsx b/customer-spa/src/pages/DynamicPage/sections/ContactFormSection.tsx new file mode 100644 index 0000000..aa38956 --- /dev/null +++ b/customer-spa/src/pages/DynamicPage/sections/ContactFormSection.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { cn } from '@/lib/utils'; + +interface ContactFormSectionProps { + id: string; + layout?: string; + colorScheme?: string; + title?: string; + webhook_url?: string; + redirect_url?: string; + fields?: string[]; +} + +export function ContactFormSection({ + id, + layout = 'default', + colorScheme = 'default', + title, + webhook_url, + redirect_url, + fields = ['name', 'email', 'message'], +}: ContactFormSectionProps) { + const [formData, setFormData] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + + try { + // Submit to webhook if provided + if (webhook_url) { + await fetch(webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + } + + // Redirect after submission + if (redirect_url) { + // Replace placeholders in redirect URL + let finalUrl = redirect_url; + Object.entries(formData).forEach(([key, value]) => { + finalUrl = finalUrl.replace(`{${key}}`, encodeURIComponent(value)); + }); + window.location.href = finalUrl; + } + } catch (err) { + setError('Failed to submit form. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ {title && ( +

+ {title} +

+ )} + +
+ {fields.map((field) => { + const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' '); + const isTextarea = field === 'message' || field === 'content'; + + return ( +
+ + {isTextarea ? ( +