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:
@@ -19,6 +19,7 @@ import Wishlist from './pages/Wishlist';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import ForgotPassword from './pages/ForgotPassword';
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
import { DynamicPageRenderer } from './pages/DynamicPage';
|
||||||
|
|
||||||
// Create QueryClient instance
|
// Create QueryClient instance
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -92,8 +93,11 @@ function AppRoutes() {
|
|||||||
{/* My Account */}
|
{/* My Account */}
|
||||||
<Route path="/my-account/*" element={<Account />} />
|
<Route path="/my-account/*" element={<Account />} />
|
||||||
|
|
||||||
{/* Fallback to initial route */}
|
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
|
||||||
<Route path="*" element={<Navigate to={initialRoute} replace />} />
|
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
|
||||||
|
|
||||||
|
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
|
||||||
|
<Route path="/:slug" element={<DynamicPageRenderer />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
194
customer-spa/src/pages/DynamicPage/index.tsx
Normal file
194
customer-spa/src/pages/DynamicPage/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
customer-spa/src/pages/DynamicPage/sections/HeroSection.tsx
Normal file
116
customer-spa/src/pages/DynamicPage/sections/HeroSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user