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
This commit is contained in:
Dwindi Ramadhana
2026-01-11 22:35:15 +07:00
parent 9331989102
commit 749cfb3f92
8 changed files with 748 additions and 2 deletions

View File

@@ -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<string, SectionProp | any>;
}
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<string, any>;
rendered?: {
sections: Section[];
};
}
// Section renderer map
const SECTION_COMPONENTS: Record<string, React.ComponentType<any>> = {
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<PageData>({
queryKey: ['dynamic-page', pathBase, slug],
queryFn: async (): Promise<PageData> => {
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 (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
// 404 state
if (notFound || !pageData) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
<p className="text-gray-600 mb-8">Page not found</p>
<button
onClick={() => navigate('/')}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
Go Home
</button>
</div>
);
}
return (
<>
{/* SEO */}
<Helmet>
<title>{pageData.seo?.meta_title || pageData.title || 'Page'}</title>
{pageData.seo?.meta_description && (
<meta name="description" content={pageData.seo.meta_description} />
)}
{pageData.seo?.canonical && (
<link rel="canonical" href={pageData.seo.canonical} />
)}
{pageData.seo?.og_title && (
<meta property="og:title" content={pageData.seo.og_title} />
)}
{pageData.seo?.og_description && (
<meta property="og:description" content={pageData.seo.og_description} />
)}
{pageData.seo?.og_image && (
<meta property="og:image" content={pageData.seo.og_image} />
)}
</Helmet>
{/* Render sections */}
<div className="wn-page">
{sections.map((section) => {
const SectionComponent = SECTION_COMPONENTS[section.type];
if (!SectionComponent) {
// Fallback for unknown section types
return (
<div key={section.id} className={`wn-section wn-${section.type}`}>
<pre className="text-xs text-gray-500">
Unknown section type: {section.type}
</pre>
</div>
);
}
return (
<SectionComponent
key={section.id}
id={section.id}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
{...section.props}
/>
);
})}
{/* Empty state */}
{sections.length === 0 && (
<div className="py-20 text-center text-gray-500">
<p>This page has no content yet.</p>
</div>
)}
</div>
</>
);
}

View File

@@ -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 (
<section
id={id}
className={cn(
'wn-section wn-cta-banner',
`wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`,
'py-16 md:py-20',
{
'bg-primary text-primary-foreground': colorScheme === 'primary',
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
'bg-gradient-to-r from-primary to-secondary text-white': colorScheme === 'gradient',
'bg-muted': colorScheme === 'muted',
}
)}
>
<div className="container mx-auto px-4 text-center">
{title && (
<h2 className="wn-cta-banner__title text-3xl md:text-4xl font-bold mb-4">
{title}
</h2>
)}
{text && (
<p className={cn(
'wn-cta-banner__text text-lg md:text-xl mb-8 max-w-2xl mx-auto',
{
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
'text-gray-600': colorScheme === 'muted',
}
)}>
{text}
</p>
)}
{button_text && button_url && (
<a
href={button_url}
className={cn(
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:transform hover:scale-105',
{
'bg-white text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
'bg-primary text-white': colorScheme === 'muted',
}
)}
>
{button_text}
</a>
)}
</div>
</section>
);
}

View File

@@ -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<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
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 (
<section
id={id}
className={cn(
'wn-section wn-contact-form',
`wn-scheme--${colorScheme}`,
'py-16 md:py-20',
{
'bg-white': colorScheme === 'default',
'bg-muted': colorScheme === 'muted',
}
)}
>
<div className="container mx-auto px-4">
<div className={cn(
'max-w-xl mx-auto',
{
'max-w-2xl': layout === 'wide',
}
)}>
{title && (
<h2 className="wn-contact-form__title text-3xl font-bold text-center mb-8">
{title}
</h2>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{fields.map((field) => {
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' ');
const isTextarea = field === 'message' || field === 'content';
return (
<div key={field} className="wn-contact-form__field">
<label className="block text-sm font-medium text-gray-700 mb-2">
{fieldLabel}
</label>
{isTextarea ? (
<textarea
name={field}
value={formData[field] || ''}
onChange={handleChange}
rows={5}
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
/>
) : (
<input
type={field === 'email' ? 'email' : 'text'}
name={field}
value={formData[field] || ''}
onChange={handleChange}
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
/>
)}
</div>
);
})}
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={submitting}
className={cn(
'w-full py-3 px-6 bg-primary text-primary-foreground rounded-lg font-semibold',
'hover:bg-primary/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{submitting ? 'Sending...' : 'Submit'}
</button>
</form>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/lib/utils';
interface ContentSectionProps {
id: string;
layout?: string;
colorScheme?: string;
content?: string;
}
export function ContentSection({
id,
layout = 'default',
colorScheme = 'default',
content,
}: ContentSectionProps) {
if (!content) return null;
return (
<section
id={id}
className={cn(
'wn-section wn-content',
`wn-scheme--${colorScheme}`,
'py-12 md:py-16',
{
'bg-white': colorScheme === 'default',
'bg-muted': colorScheme === 'muted',
'bg-primary/5': colorScheme === 'primary',
}
)}
>
<div className="container mx-auto px-4">
<div
className={cn(
'prose prose-lg max-w-none',
{
'max-w-3xl mx-auto': layout === 'narrow',
'max-w-4xl mx-auto': layout === 'medium',
}
)}
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,94 @@
import { cn } from '@/lib/utils';
interface FeatureItem {
title?: string;
description?: string;
icon?: string;
}
interface FeatureGridSectionProps {
id: string;
layout?: string;
colorScheme?: string;
heading?: string;
items?: FeatureItem[];
}
export function FeatureGridSection({
id,
layout = 'grid-3',
colorScheme = 'default',
heading,
items = [],
}: FeatureGridSectionProps) {
const gridCols = {
'grid-2': 'md:grid-cols-2',
'grid-3': 'md:grid-cols-3',
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
}[layout] || 'md:grid-cols-3';
return (
<section
id={id}
className={cn(
'wn-section wn-feature-grid',
`wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`,
'py-16 md:py-24',
{
'bg-white': colorScheme === 'default',
'bg-muted': colorScheme === 'muted',
'bg-primary text-primary-foreground': colorScheme === 'primary',
}
)}
>
<div className="container mx-auto px-4">
{heading && (
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12">
{heading}
</h2>
)}
<div className={cn('grid gap-8', gridCols)}>
{items.map((item, index) => (
<div
key={index}
className={cn(
'wn-feature-grid__item',
'p-6 rounded-xl',
{
'bg-white shadow-lg': colorScheme !== 'primary',
'bg-white/10': colorScheme === 'primary',
}
)}
>
{item.icon && (
<span className="wn-feature-grid__icon text-4xl mb-4 block">
{item.icon}
</span>
)}
{item.title && (
<h3 className="wn-feature-grid__item-title text-xl font-semibold mb-3">
{item.title}
</h3>
)}
{item.description && (
<p className={cn(
'wn-feature-grid__item-desc',
{
'text-gray-600': colorScheme !== 'primary',
'text-white/80': colorScheme === 'primary',
}
)}>
{item.description}
</p>
)}
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,116 @@
import { cn } from '@/lib/utils';
interface HeroSectionProps {
id: string;
layout?: string;
colorScheme?: string;
title?: string;
subtitle?: string;
image?: string;
cta_text?: string;
cta_url?: string;
}
export function HeroSection({
id,
layout = 'default',
colorScheme = 'default',
title,
subtitle,
image,
cta_text,
cta_url,
}: HeroSectionProps) {
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
const isCentered = layout === 'centered' || layout === 'default';
return (
<section
id={id}
className={cn(
'wn-section wn-hero',
`wn-hero--${layout}`,
`wn-scheme--${colorScheme}`,
'relative overflow-hidden',
{
'bg-primary text-primary-foreground': colorScheme === 'primary',
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
'bg-muted': colorScheme === 'muted',
'bg-gradient-to-r from-primary/10 to-secondary/10': colorScheme === 'gradient',
}
)}
>
<div className={cn(
'container mx-auto px-4 py-16 md:py-24',
{
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
'text-center': isCentered,
}
)}>
{/* Image - Left */}
{image && isImageLeft && (
<div className="w-full md:w-1/2">
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto rounded-lg shadow-xl"
/>
</div>
)}
{/* Content */}
<div className={cn(
'wn-hero__content',
{
'w-full md:w-1/2': isImageLeft || isImageRight,
'max-w-3xl mx-auto': isCentered,
}
)}>
{title && (
<h1 className="wn-hero__title text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-6">
{title}
</h1>
)}
{subtitle && (
<p className="wn-hero__subtitle text-lg md:text-xl text-opacity-80 mb-8">
{subtitle}
</p>
)}
{cta_text && cta_url && (
<a
href={cta_url}
className="wn-hero__cta inline-block px-8 py-3 bg-primary text-primary-foreground rounded-lg font-semibold hover:bg-primary/90 transition-colors"
>
{cta_text}
</a>
)}
</div>
{/* Image - Right */}
{image && isImageRight && (
<div className="w-full md:w-1/2">
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto rounded-lg shadow-xl"
/>
</div>
)}
{/* Centered Image */}
{image && isCentered && (
<div className="mt-12">
<img
src={image}
alt={title || 'Hero image'}
className="w-full max-w-4xl mx-auto h-auto rounded-lg shadow-xl"
/>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,75 @@
import { cn } from '@/lib/utils';
interface ImageTextSectionProps {
id: string;
layout?: string;
colorScheme?: string;
title?: string;
text?: string;
image?: string;
}
export function ImageTextSection({
id,
layout = 'image-left',
colorScheme = 'default',
title,
text,
image,
}: ImageTextSectionProps) {
const isImageLeft = layout === 'image-left' || layout === 'left';
const isImageRight = layout === 'image-right' || layout === 'right';
return (
<section
id={id}
className={cn(
'wn-section wn-image-text',
`wn-image-text--${layout}`,
`wn-scheme--${colorScheme}`,
'py-16 md:py-24',
{
'bg-white': colorScheme === 'default',
'bg-muted': colorScheme === 'muted',
'bg-primary/5': colorScheme === 'primary',
}
)}
>
<div className="container mx-auto px-4">
<div className={cn(
'flex flex-col md:flex-row items-center gap-8 md:gap-16',
{
'md:flex-row-reverse': isImageRight,
}
)}>
{/* Image */}
{image && (
<div className="w-full md:w-1/2">
<img
src={image}
alt={title || 'Section image'}
className="w-full h-auto rounded-xl shadow-lg"
/>
</div>
)}
{/* Content */}
<div className="w-full md:w-1/2">
{title && (
<h2 className="text-3xl md:text-4xl font-bold mb-6">
{title}
</h2>
)}
{text && (
<div
className="prose prose-lg text-gray-600"
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
</div>
</div>
</div>
</section>
);
}