Fix button roundtrip in editor, alignment persistence, and test email rendering

This commit is contained in:
Dwindi Ramadhana
2026-01-17 13:10:50 +07:00
parent 0e9ace902d
commit 6d2136d3b5
61 changed files with 8287 additions and 866 deletions

View File

@@ -19,11 +19,29 @@ interface SectionProp {
source?: string;
}
interface SectionStyles {
backgroundColor?: string;
backgroundImage?: string;
backgroundOverlay?: number;
paddingTop?: string;
paddingBottom?: string;
contentWidth?: 'full' | 'contained';
}
interface ElementStyle {
color?: string;
fontSize?: string;
fontWeight?: string;
textAlign?: 'left' | 'center' | 'right';
}
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
styles?: SectionStyles;
elementStyles?: Record<string, ElementStyle>;
props: Record<string, SectionProp | any>;
}
@@ -64,32 +82,64 @@ const SECTION_COMPONENTS: Record<string, React.ComponentType<any>> = {
'contact_form': ContactFormSection,
};
/**
* Flatten section props by extracting .value from {type, value} objects
* This transforms { title: { type: 'static', value: 'Hello' } }
* into { title: 'Hello' }
*/
function flattenSectionProps(props: Record<string, any>): Record<string, any> {
const flattened: Record<string, any> = {};
for (const [key, value] of Object.entries(props)) {
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
// This is a {type, value} prop structure
flattened[key] = value.value;
} else if (value && typeof value === 'object' && 'type' in value && 'source' in value) {
// This is a dynamic prop - use source as placeholder for now
flattened[key] = `[${value.source}]`;
} else {
// Regular value, pass through
flattened[key] = value;
}
}
return flattened;
}
/**
* DynamicPageRenderer
* Renders structural pages and CPT template content
*/
export function DynamicPageRenderer() {
const { pathBase, slug } = useParams<{ pathBase?: string; slug?: string }>();
interface DynamicPageRendererProps {
slug?: string;
}
export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps) {
const { pathBase, slug: paramSlug } = useParams<{ pathBase?: string; slug?: string }>();
const navigate = useNavigate();
const [notFound, setNotFound] = useState(false);
// Use prop slug if provided, otherwise use param slug
const effectiveSlug = propSlug || paramSlug;
// Determine if this is a page or CPT content
const isStructuralPage = !pathBase;
// If propSlug is provided, it's treated as a structural page (pathBase is undefined)
const isStructuralPage = !pathBase || !!propSlug;
const contentType = pathBase === 'blog' ? 'post' : pathBase;
const contentSlug = slug || '';
const contentSlug = effectiveSlug || '';
// Fetch page/content data
const { data: pageData, isLoading, error } = useQuery<PageData>({
queryKey: ['dynamic-page', pathBase, slug],
queryKey: ['dynamic-page', pathBase, effectiveSlug],
queryFn: async (): Promise<PageData> => {
if (isStructuralPage) {
// Fetch structural page
const response = await api.get(`/pages/${slug}`);
return response.data;
// Fetch structural page - api.get returns JSON directly
const response = await api.get<PageData>(`/pages/${contentSlug}`);
return response;
} else {
// Fetch CPT content with template
const response = await api.get(`/content/${contentType}/${contentSlug}`);
return response.data;
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
return response;
}
},
retry: false,
@@ -172,13 +222,42 @@ export function DynamicPageRenderer() {
}
return (
<SectionComponent
<div
key={section.id}
id={section.id}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
{...section.props}
/>
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`}
style={{
backgroundColor: section.styles?.backgroundColor,
paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom,
}}
>
{/* Background Image & Overlay */}
{section.styles?.backgroundImage && (
<>
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
/>
<div
className="absolute inset-0 z-0 bg-black"
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
/>
</>
)}
{/* Content Wrapper */}
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}>
<SectionComponent
id={section.id}
section={section} // Pass full section object for components that need raw data
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}
elementStyles={section.elementStyles}
{...flattenSectionProps(section.props || {})}
/>
</div>
</div>
);
})}

View File

@@ -8,6 +8,7 @@ interface CTABannerSectionProps {
text?: string;
button_text?: string;
button_url?: string;
elementStyles?: Record<string, any>;
}
export function CTABannerSection({
@@ -18,7 +19,36 @@ export function CTABannerSection({
text,
button_text,
button_url,
}: CTABannerSectionProps) {
elementStyles,
styles,
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const btnStyle = getTextStyles('button_text');
return (
<section
id={id}
@@ -26,7 +56,7 @@ export function CTABannerSection({
'wn-section wn-cta-banner',
`wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`,
'py-16 md:py-20',
'py-12 md:py-20',
{
'bg-primary text-primary-foreground': colorScheme === 'primary',
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
@@ -35,21 +65,36 @@ export function CTABannerSection({
}
)}
>
<div className="container mx-auto px-4 text-center">
<div className={cn(
"mx-auto px-4 text-center",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
{title && (
<h2 className="wn-cta-banner__title text-3xl md:text-4xl font-bold mb-4">
<h2
className={cn(
"wn-cta__title mb-6",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
)}
{text && (
<p className={cn(
'wn-cta-banner__text text-lg md:text-xl mb-8 max-w-2xl mx-auto',
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
{
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
'text-gray-600': colorScheme === 'muted',
}
)}>
},
textStyle.classNames
)}
style={textStyle.style}
>
{text}
</p>
)}
@@ -58,12 +103,18 @@ export function CTABannerSection({
<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',
}
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
!btnStyle.style?.backgroundColor && {
'bg-white': colorScheme === 'primary' || colorScheme === 'gradient',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
},
!btnStyle.style?.color && {
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
},
btnStyle.classNames
)}
style={btnStyle.style}
>
{button_text}
</a>

View File

@@ -9,6 +9,7 @@ interface ContactFormSectionProps {
webhook_url?: string;
redirect_url?: string;
fields?: string[];
elementStyles?: Record<string, any>;
}
export function ContactFormSection({
@@ -19,8 +20,37 @@ export function ContactFormSection({
webhook_url,
redirect_url,
fields = ['name', 'email', 'message'],
}: ContactFormSectionProps) {
elementStyles,
styles,
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
const [formData, setFormData] = useState<Record<string, string>>({});
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const buttonStyle = getTextStyles('button');
const fieldsStyle = getTextStyles('fields');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -65,14 +95,18 @@ export function ContactFormSection({
className={cn(
'wn-section wn-contact-form',
`wn-scheme--${colorScheme}`,
'py-16 md:py-20',
`wn-scheme--${colorScheme}`,
'py-12 md:py-20',
{
'bg-white': colorScheme === 'default',
// 'bg-white': colorScheme === 'default', // Removed for global styling
'bg-muted': colorScheme === 'muted',
}
)}
>
<div className="container mx-auto px-4">
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
<div className={cn(
'max-w-xl mx-auto',
{
@@ -80,7 +114,14 @@ export function ContactFormSection({
}
)}>
{title && (
<h2 className="wn-contact-form__title text-3xl font-bold text-center mb-8">
<h2 className={cn(
"wn-contact__title text-center mb-12",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
)}
@@ -101,7 +142,16 @@ export function ContactFormSection({
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"
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
/>
@@ -111,7 +161,16 @@ export function ContactFormSection({
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"
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
/>
@@ -128,10 +187,14 @@ export function ContactFormSection({
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'
'w-full py-3 px-6 rounded-lg font-semibold',
'hover:opacity-90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
!buttonStyle.style?.backgroundColor && 'bg-primary',
!buttonStyle.style?.color && 'text-primary-foreground',
buttonStyle.classNames
)}
style={buttonStyle.style}
>
{submitting ? 'Sending...' : 'Submit'}
</button>

View File

@@ -1,46 +1,259 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
interface ContentSectionProps {
id: string;
layout?: string;
colorScheme?: string;
content?: string;
section: {
id: string;
layoutVariant?: string;
colorScheme?: string;
props?: {
content?: { value: string };
cta_text?: { value: string };
cta_url?: { value: string };
};
elementStyles?: Record<string, any>;
styles?: Record<string, any>;
};
}
export function ContentSection({
id,
layout = 'default',
colorScheme = 'default',
content,
}: ContentSectionProps) {
if (!content) return null;
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: 'bg-white', text: 'text-gray-900' },
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
dark: { bg: 'bg-gray-900', text: 'text-white' },
blue: { bg: 'wn-primary-bg', text: 'text-white' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
const WIDTH_CLASSES: Record<string, string> = {
default: 'max-w-screen-xl mx-auto',
contained: 'max-w-screen-md mx-auto',
full: 'w-full',
};
const fontSizeToCSS = (className?: string) => {
switch (className) {
case 'text-xs': return '0.75rem';
case 'text-sm': return '0.875rem';
case 'text-base': return '1rem';
case 'text-lg': return '1.125rem';
case 'text-xl': return '1.25rem';
case 'text-2xl': return '1.5rem';
case 'text-3xl': return '1.875rem';
case 'text-4xl': return '2.25rem';
case 'text-5xl': return '3rem';
case 'text-6xl': return '3.75rem';
case 'text-7xl': return '4.5rem';
case 'text-8xl': return '6rem';
case 'text-9xl': return '8rem';
default: return undefined;
}
};
const fontWeightToCSS = (className?: string) => {
switch (className) {
case 'font-thin': return '100';
case 'font-extralight': return '200';
case 'font-light': return '300';
case 'font-normal': return '400';
case 'font-medium': return '500';
case 'font-semibold': return '600';
case 'font-bold': return '700';
case 'font-extrabold': return '800';
case 'font-black': return '900';
default: return undefined;
}
};
// Helper to generate scoped CSS for prose elements
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
const styles: string[] = [];
const scope = `#${sectionId}`; // ContentSection uses id directly on section tag
// Headings (h1-h4)
const hs = elementStyles?.heading;
if (hs) {
const headingRules = [
hs.color && `color: ${hs.color} !important;`,
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
].filter(Boolean).join(' ');
if (headingRules) {
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
}
}
// Body text (p, li)
const ts = elementStyles?.text;
if (ts) {
const textRules = [
ts.color && `color: ${ts.color} !important;`,
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
].filter(Boolean).join(' ');
if (textRules) {
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
}
}
// Explicit Spacing & List Formatting Restorations
styles.push(`
${scope} p { margin-bottom: 1em; }
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
${scope} li { margin-bottom: 0.25em; }
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
`);
// Links (a:not(.button))
const ls = elementStyles?.link;
if (ls) {
const linkRules = [
ls.color && `color: ${ls.color} !important;`,
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
].filter(Boolean).join(' ');
if (linkRules) {
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
}
if (ls.hoverColor) {
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
}
}
// Buttons (a[data-button], .button)
const bs = elementStyles?.button;
if (bs) {
const btnRules = [
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
bs.color && `color: ${bs.color} !important;`,
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
bs.padding && `padding: ${bs.padding} !important;`,
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
].filter(Boolean).join(' ');
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
}
// Images
const is = elementStyles?.image;
if (is) {
const imgRules = [
is.objectFit && `object-fit: ${is.objectFit} !important;`,
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
is.width && `width: ${is.width} !important;`,
is.height && `height: ${is.height} !important;`,
].filter(Boolean).join(' ');
if (imgRules) {
styles.push(`${scope} img { ${imgRules} }`);
}
}
return styles.join('\n');
};
export function ContentSection({ section }: ContentSectionProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
// Default to 'default' width if not specified
const layout = section.layoutVariant || 'default';
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
const heightPreset = section.styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = section.props?.content?.value || '';
// Helper to get text styles
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const contentStyle = getTextStyles('content');
const headingStyle = getTextStyles('heading');
const textStyle = getTextStyles('text');
const buttonStyle = getTextStyles('button');
const containerWidth = section.styles?.contentWidth || 'contained';
const cta_text = section.props?.cta_text?.value;
const cta_url = section.props?.cta_url?.value;
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
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 }}
<>
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
<section
id={section.id}
className={cn(
'wn-content',
'px-4 md:px-8',
heightClasses,
!scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text
)}
style={getBackgroundStyle()}
>
<SharedContentLayout
text={content}
textStyle={textStyle.style}
headingStyle={headingStyle.style}
containerWidth={containerWidth as any}
className={contentStyle.classNames}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
classNames: buttonStyle.classNames,
style: buttonStyle.style
}}
/>
</div>
</section>
</section>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react';
interface FeatureItem {
title?: string;
@@ -12,6 +13,7 @@ interface FeatureGridSectionProps {
colorScheme?: string;
heading?: string;
items?: FeatureItem[];
elementStyles?: Record<string, any>;
}
export function FeatureGridSection({
@@ -20,13 +22,44 @@ export function FeatureGridSection({
colorScheme = 'default',
heading,
items = [],
}: FeatureGridSectionProps) {
features = [],
elementStyles,
styles,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
// Use items or features (priority to items if both exist, but usually only one comes from props)
const listItems = items.length > 0 ? items : features;
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';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item');
return (
<section
id={id}
@@ -34,42 +67,65 @@ export function FeatureGridSection({
'wn-section wn-feature-grid',
`wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`,
'py-16 md:py-24',
'py-12 md:py-24',
{
'bg-white': colorScheme === 'default',
// 'bg-white': colorScheme === 'default', // Removed for global styling
'bg-muted': colorScheme === 'muted',
'bg-primary text-primary-foreground': colorScheme === 'primary',
}
)}
>
<div className="container mx-auto px-4">
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
{heading && (
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12">
<h2
className={cn(
"wn-features__heading text-center mb-12",
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.heading?.fontWeight && "font-bold",
headingStyle.classNames
)}
style={headingStyle.style}
>
{heading}
</h2>
)}
<div className={cn('grid gap-8', gridCols)}>
{items.map((item, index) => (
{listItems.map((item, index) => (
<div
key={index}
className={cn(
'wn-feature-grid__item',
'p-6 rounded-xl',
{
!featureItemStyle.style?.backgroundColor && {
'bg-white shadow-lg': colorScheme !== 'primary',
'bg-white/10': colorScheme === 'primary',
}
},
featureItemStyle.classNames
)}
style={featureItemStyle.style}
>
{item.icon && (
<span className="wn-feature-grid__icon text-4xl mb-4 block">
{item.icon}
</span>
)}
{item.icon && (() => {
const IconComponent = (LucideIcons as any)[item.icon];
if (!IconComponent) return null;
return (
<div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
<IconComponent className="w-8 h-8" />
</div>
);
})()}
{item.title && (
<h3 className="wn-feature-grid__item-title text-xl font-semibold mb-3">
<h3
className={cn(
"wn-feature-grid__item-title mb-3",
!featureItemStyle.classNames && "text-xl font-semibold"
)}
style={{ color: featureItemStyle.style?.color }}
>
{item.title}
</h3>
)}
@@ -77,11 +133,13 @@ export function FeatureGridSection({
{item.description && (
<p className={cn(
'wn-feature-grid__item-desc',
{
!featureItemStyle.style?.color && {
'text-gray-600': colorScheme !== 'primary',
'text-white/80': colorScheme === 'primary',
}
)}>
)}
style={{ color: featureItemStyle.style?.color }}
>
{item.description}
</p>
)}

View File

@@ -9,6 +9,7 @@ interface HeroSectionProps {
image?: string;
cta_text?: string;
cta_url?: string;
elementStyles?: Record<string, any>;
}
export function HeroSection({
@@ -20,11 +21,72 @@ export function HeroSection({
image,
cta_text,
cta_url,
}: HeroSectionProps) {
elementStyles,
styles,
}: HeroSectionProps & { styles?: Record<string, any> }) {
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
const isCentered = layout === 'centered' || layout === 'default';
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const ctaStyle = getTextStyles('cta_text');
const imageStyle = elementStyles?.['image'] || {};
// Determine height classes
const heightPreset = styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-24', // Original Default
'small': 'py-8 md:py-16',
'medium': 'py-16 md:py-32',
'large': 'py-24 md:py-48',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (hasCustomBackground) return undefined;
if (colorScheme === 'gradient') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground;
return (
<section
id={id}
@@ -33,16 +95,15 @@ export function HeroSection({
`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',
}
isDynamicScheme && 'text-white',
colorScheme === 'muted' && !hasCustomBackground && 'bg-muted',
)}
style={getBackgroundStyle()}
>
<div className={cn(
'container mx-auto px-4 py-16 md:py-24',
'mx-auto px-4',
heightClasses,
styles?.contentWidth === 'full' ? 'w-full' : 'container',
{
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
'text-center': isCentered,
@@ -51,11 +112,24 @@ export function HeroSection({
{/* 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
className="rounded-lg shadow-xl overflow-hidden"
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%'
}}
>
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
}}
/>
</div>
</div>
)}
@@ -68,21 +142,68 @@ export function HeroSection({
}
)}>
{title && (
<h1 className="wn-hero__title text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-6">
<h1
className={cn(
"wn-hero__title mb-6 leading-tight",
!elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h1>
)}
{subtitle && (
<p className="wn-hero__subtitle text-lg md:text-xl text-opacity-80 mb-8">
<p
className={cn(
"wn-hero__subtitle text-opacity-80 mb-8",
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
subtitleStyle.classNames
)}
style={subtitleStyle.style}
>
{subtitle}
</p>
)}
{/* Centered Image */}
<div
className={cn(
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden",
imageStyle.width ? "" : "max-w-4xl"
)}
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
>
{image && isCentered && (
<img
src={image}
alt={title || 'Hero image'}
className={cn(
"w-full rounded-[inherit]",
!imageStyle.height && "h-auto",
!imageStyle.objectFit && "object-cover"
)}
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
maxWidth: '100%',
}}
/>
)}
</div>
{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"
className={cn(
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
!ctaStyle.style?.backgroundColor && "bg-primary",
!ctaStyle.style?.color && "text-primary-foreground",
ctaStyle.classNames
)}
style={ctaStyle.style}
>
{cta_text}
</a>
@@ -92,22 +213,24 @@ export function HeroSection({
{/* 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
className="rounded-lg shadow-xl overflow-hidden"
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%'
}}
>
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
}}
/>
</div>
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
interface ImageTextSectionProps {
id: string;
@@ -7,6 +8,7 @@ interface ImageTextSectionProps {
title?: string;
text?: string;
image?: string;
elementStyles?: Record<string, any>;
}
export function ImageTextSection({
@@ -16,60 +18,87 @@ export function ImageTextSection({
title,
text,
image,
}: ImageTextSectionProps) {
cta_text,
cta_url,
elementStyles,
styles,
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
const isImageLeft = layout === 'image-left' || layout === 'left';
const isImageRight = layout === 'image-right' || layout === 'right';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const buttonStyle = getTextStyles('button');
const imageStyle = elementStyles?.['image'] || {};
// Height preset support
const heightPreset = styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-24',
'small': 'py-8 md:py-16',
'medium': 'py-16 md:py-32',
'large': 'py-24 md:py-48',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
return (
<section
id={id}
className={cn(
'wn-section wn-image-text',
`wn-image-text--${layout}`,
`wn-scheme--${colorScheme}`,
'py-16 md:py-24',
heightClasses,
{
'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>
<SharedContentLayout
title={title}
text={text}
image={image}
imagePosition={isImageRight ? 'right' : 'left'}
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
titleStyle={titleStyle.style}
titleClassName={titleStyle.classNames}
textStyle={textStyle.style}
textClassName={textStyle.classNames}
imageStyle={{
backgroundColor: imageStyle.backgroundColor,
objectFit: imageStyle.objectFit,
}}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
classNames: buttonStyle.classNames,
style: buttonStyle.style
}}
/>
</section>
);
}