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:
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