feat: refine dynamic page sections, fix renderers, improve styling controls and editor schema

This commit is contained in:
Dwindi Ramadhana
2026-06-11 21:24:57 +07:00
parent 54a3a15f68
commit ec2049913f
33 changed files with 1032 additions and 822 deletions

View File

@@ -22,6 +22,7 @@ interface SharedContentProps {
textClassName?: string;
headingStyle?: React.CSSProperties; // For prose headings override
imageStyle?: React.CSSProperties;
cardStyle?: React.CSSProperties; // For boxed layout background
// Pro Features (for future)
buttons?: Array<{ text: string, url: string }>;
@@ -44,6 +45,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
buttons,
imageStyle,
cardStyle,
buttonStyle
}) => {
@@ -56,7 +58,8 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
// Wrapper classes — no width constraints applied here, parent handles it
const containerClasses = cn(
'w-full mx-auto px-4 sm:px-6 lg:px-8',
containerWidth === 'contained' ? 'max-w-4xl' : '' // only constraint needed is for contained narrow text
containerWidth === 'contained' ? 'max-w-4xl' : '',
containerWidth === 'boxed' ? 'max-w-5xl' : ''
);
const gridClasses = cn(
@@ -68,8 +71,11 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
const safeTextStyle = { ...textStyle };
delete safeTextStyle.textAlign;
const proseStyle = {
...textStyle,
...safeTextStyle,
'--tw-prose-headings': headingStyle?.color,
'--tw-prose-body': textStyle?.color,
} as React.CSSProperties;
@@ -80,15 +86,33 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
'flex flex-col',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
(isImageTop || isImageBottom) && 'mb-8',
{
'items-start': (imageStyle as any)?.alignment === 'left',
'items-center': (imageStyle as any)?.alignment === 'center',
'items-end': (imageStyle as any)?.alignment === 'right',
}
)}>
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
)} style={{
backgroundColor: imageStyle?.backgroundColor,
width: imageStyle?.width,
height: imageStyle?.height,
maxWidth: '100%'
}}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
style={{
objectFit: imageStyle?.objectFit,
objectPosition: (imageStyle as any)?.objectPosition,
}}
/>
</div>
</div>
)}
@@ -127,7 +151,12 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
<div className={cn(
"mt-8 flex flex-wrap gap-4",
buttonStyle?.style?.textAlign === 'center' && "justify-center",
buttonStyle?.style?.textAlign === 'right' && "justify-end",
(!buttonStyle?.style?.textAlign || buttonStyle?.style?.textAlign === 'left') && "justify-start"
)}>
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a

View File

@@ -35,6 +35,12 @@ interface SectionStyles {
gradientAngle?: number;
gradientFrom?: string;
gradientTo?: string;
cardBackgroundColor?: string;
cardPaddingTop?: string;
cardPaddingRight?: string;
cardPaddingBottom?: string;
cardPaddingLeft?: string;
heightPreset?: string;
}
interface ElementStyle {
@@ -272,7 +278,14 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
key={section.id}
className={cn(
"relative overflow-hidden",
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50",
{
'default': 'py-16 md:py-24',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-screen flex items-center',
}[(section.styles?.heightPreset as string) || 'default'] || 'py-16 md:py-24'
)}
style={{
...(section.styles?.backgroundType === 'gradient'
@@ -313,10 +326,21 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
{/* Content Wrapper */}
{section.styles?.contentWidth === 'boxed' ? (
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div
className="rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
style={{
backgroundColor: section.styles?.cardBackgroundColor || '#ffffff',
paddingTop: section.styles?.cardPaddingTop || undefined,
paddingRight: section.styles?.cardPaddingRight || undefined,
paddingBottom: section.styles?.cardPaddingBottom || undefined,
paddingLeft: section.styles?.cardPaddingLeft || undefined,
}}
>
<SectionComponent
id={section.id}
section={section}
sourceType={isStructuralPage ? 'page' : 'template'}
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}
@@ -333,6 +357,8 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
<SectionComponent
id={section.id}
section={section}
sourceType={isStructuralPage ? 'page' : 'template'}
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}

View File

@@ -7,6 +7,7 @@ interface BentoItem {
label: string;
image?: string;
url?: string;
backgroundColor?: string;
size?: 'small' | 'medium' | 'large' | 'tall';
}
@@ -69,6 +70,31 @@ export function BentoCategoryGrid({
});
})();
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const es = elementStyles?.[elementName] || {};
return {
classNames: cn(
es.fontSize,
es.fontWeight,
{
'font-sans': es.fontFamily === 'secondary',
'font-serif': es.fontFamily === 'primary',
}
),
style: {
color: es.color,
textAlign: es.textAlign,
backgroundColor: es.backgroundColor,
borderColor: es.borderColor,
borderWidth: es.borderWidth,
borderRadius: es.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
@@ -76,14 +102,17 @@ export function BentoCategoryGrid({
<section
id={id}
className={cn("wn-section wn-bento-grid relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className="w-full mx-auto px-4 relative z-10">
{title && (
<h2
className="text-3xl md:text-4xl font-bold mb-8"
style={{ color: elementStyles?.title?.color }}
className={cn(
"mb-8",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
@@ -109,6 +138,13 @@ export function BentoCategoryGrid({
alt={item.label}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : item.backgroundColor ? (
<div
className="absolute inset-0"
style={{
background: `linear-gradient(to bottom right, ${item.backgroundColor}, color-mix(in srgb, ${item.backgroundColor}, black 35%))`
}}
/>
) : (
<div className={cn('absolute inset-0 bg-gradient-to-br', gradientClass)} />
)}

View File

@@ -23,7 +23,9 @@ export function CTABannerSection({
button_url,
elementStyles,
styles,
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
isEditor,
}: CTABannerSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
@@ -84,58 +86,59 @@ export function CTABannerSection({
styles?.contentWidth !== 'boxed' && {
'text-white/90': colorScheme === 'primary',
'text-gray-600': colorScheme === 'muted',
'text-gray-700': colorScheme === 'default',
},
styles?.contentWidth === 'boxed' && 'text-gray-600',
textStyle.classNames
)}
style={textStyle.style}
>
{text}
{text || "Description text missing"}
</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:opacity-90',
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
? 'bg-primary'
: {
'bg-white': colorScheme === 'primary',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
}),
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
? 'text-primary-foreground'
: {
'text-primary': colorScheme === 'primary',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
}),
btnStyle.classNames
)}
style={btnStyle.style}
>
{button_text}
</a>
<div className="w-full mt-4" style={{ textAlign: (btnStyle.style.textAlign as React.CSSProperties['textAlign']) || 'center' }}>
<a
href={button_url}
className={cn(
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
? 'bg-primary'
: {
'bg-white': colorScheme === 'primary',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
}),
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
? 'text-primary-foreground'
: {
'text-primary': colorScheme === 'primary',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
}),
btnStyle.classNames
)}
style={{ ...btnStyle.style, textAlign: undefined }}
>
{button_text}
</a>
</div>
)}
</>
);
const sectionBg = getSectionBackground(styles);
const isBoxed = styles?.contentWidth === 'boxed';
return (
<section
id={id}
className={cn(
'wn-section wn-cta-banner relative overflow-hidden w-full',
'wn-section wn-cta-banner relative w-full flex flex-col items-center justify-center',
`wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`,
heightClasses
heightClasses // Might not be needed if handled by outer, but safe to keep
)}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className="mx-auto px-4 text-center relative z-10 w-full">
<div className="mx-auto px-4 text-center relative z-10 w-full max-w-5xl">
{innerContent}
</div>
</section>

View File

@@ -2,38 +2,42 @@ import { useState } from 'react';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
import { api } from '@/lib/api/client';
interface ContactFormField {
name: string;
label: string;
type: string;
required?: boolean;
}
interface ContactFormSectionProps {
id: string;
sourceType?: string;
sourceId?: string;
layout?: string;
colorScheme?: string;
title?: string;
webhook_url?: string;
redirect_url?: string;
fields?: string[];
fields?: ContactFormField[];
elementStyles?: Record<string, any>;
}
export function ContactFormSection({
id,
sourceType,
sourceId,
layout = 'default',
colorScheme = 'default',
title,
webhook_url,
redirect_url,
fields = ['name', 'email', 'message'],
fields,
elementStyles,
styles,
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
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-36',
'fullscreen': 'min-h-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
isEditor,
}: ContactFormSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
const [formData, setFormData] = useState<Record<string, string>>({});
// Helper to get text styles (including font family)
@@ -64,23 +68,82 @@ export function ContactFormSection({
const fieldsStyle = getTextStyles('fields');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const defaultFields: ContactFormField[] = [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Your Email', type: 'email', required: true },
{ name: 'message', label: 'Your Message', type: 'textarea', required: true },
];
const activeFields = Array.isArray(fields) && fields.length > 0 ? fields : defaultFields;
const validateField = (name: string, value: string, field: ContactFormField) => {
if (field.required && !value?.trim()) {
return `${field.label} is required`;
}
if (field.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email address';
}
return '';
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user types
if (fieldErrors[name]) {
setFieldErrors(prev => ({ ...prev, [name]: '' }));
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
const field = activeFields.find(f => f.name === name);
if (field) {
const errorMsg = validateField(name, value, field);
setFieldErrors(prev => ({ ...prev, [name]: errorMsg }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const newErrors: Record<string, string> = {};
let isValid = true;
activeFields.forEach(field => {
const errorMsg = validateField(field.name, formData[field.name] || '', field);
if (errorMsg) {
newErrors[field.name] = errorMsg;
isValid = false;
}
});
if (!isValid) {
setFieldErrors(newErrors);
// Mark all fields with errors as touched
const allTouched = Object.keys(newErrors).reduce((acc, key) => ({...acc, [key]: true}), {});
setTouched(prev => ({ ...prev, ...allTouched }));
return;
}
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),
// Submit to webhook proxy if configured
if (webhook_url && !isEditor) {
await api.post('/pages/submit-section-form', {
source_type: sourceType,
source_id: sourceId,
section_id: id,
form_data: formData,
});
}
@@ -107,12 +170,9 @@ export function ContactFormSection({
id={id}
className={cn(
'wn-section wn-contact-form relative overflow-hidden w-full',
`wn-scheme--${colorScheme}`,
heightClasses
`wn-scheme--${colorScheme}`
)}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className="mx-auto px-4 relative z-10 w-full">
<div className={cn(
'max-w-xl mx-auto',
@@ -133,55 +193,62 @@ export function ContactFormSection({
</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';
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
{activeFields.map((field, idx) => {
const isTextarea = field.type === 'textarea';
const fieldError = fieldErrors[field.name];
const isTouched = touched[field.name];
const showError = isTouched && fieldError;
return (
<div key={field} className="wn-contact-form__field">
<div key={field.name || idx} className="wn-contact-form__field">
<label className="block text-sm font-medium text-gray-700 mb-2">
{fieldLabel}
{field.label} {field.required && <span className="text-red-500">*</span>}
</label>
{isTextarea ? (
<textarea
name={field}
value={formData[field] || ''}
name={field.name}
value={formData[field.name] || ''}
onChange={handleChange}
onBlur={handleBlur}
rows={5}
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
placeholder={`Enter ${field.label.toLowerCase()}`}
/>
) : (
<input
type={field === 'email' ? 'email' : 'text'}
name={field}
value={formData[field] || ''}
type={field.type || 'text'}
name={field.name}
value={formData[field.name] || ''}
onChange={handleChange}
onBlur={handleBlur}
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
placeholder={`Enter ${field.label.toLowerCase()}`}
/>
)}
{showError && (
<p className="mt-1 text-sm text-red-500">{fieldError}</p>
)}
</div>
);
})}

View File

@@ -48,6 +48,14 @@ const fontSizeToCSS = (className?: string) => {
}
};
const fontFamilyToCSS = (fontFamily?: string) => {
switch (fontFamily) {
case 'primary': return "'Playfair Display', Georgia, serif";
case 'secondary': return "'Inter', system-ui, sans-serif";
default: return undefined;
}
};
const fontWeightToCSS = (className?: string) => {
switch (className) {
case 'font-thin': return '100';
@@ -74,10 +82,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
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.fontFamily && `font-family: ${fontFamilyToCSS(hs.fontFamily)} !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;`,
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px;`,
].filter(Boolean).join(' ');
if (headingRules) {
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
@@ -91,7 +99,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
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;`,
ts.fontFamily && `font-family: ${fontFamilyToCSS(ts.fontFamily)} !important;`,
].filter(Boolean).join(' ');
if (textRules) {
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
@@ -147,6 +155,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
if (is) {
const imgRules = [
is.objectFit && `object-fit: ${is.objectFit} !important;`,
is.objectPosition && `object-position: ${is.objectPosition} !important;`,
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
is.width && `width: ${is.width} !important;`,
is.height && `height: ${is.height} !important;`,
@@ -159,26 +168,15 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
return styles.join('\n');
};
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl }: ContentSectionProps & { outerPadding?: boolean }) {
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl, isEditor }: ContentSectionProps & { outerPadding?: boolean; isEditor?: boolean }) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
// Default to 'default' width if not specified
const _layout = section.layoutVariant || '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',
'fullscreen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const sectionBg = getSectionBackground(section.styles);
const finalHeightClasses = (section.styles?.paddingTop || section.styles?.paddingBottom) ? '' : heightClasses;
const content = propContent || section.props?.content?.value || section.props?.content || '';
const content = propContent !== undefined ? propContent : (section.props?.content?.value ?? '');
// Helper to get text styles
const getTextStyles = (elementName: string) => {
@@ -206,27 +204,25 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
const buttonStyle = getTextStyles('button');
const containerWidth = section.styles?.contentWidth ?? 'contained';
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
const cta_text = propCtaText !== undefined ? propCtaText : (section.props?.cta_text?.value ?? '');
const cta_url = propCtaUrl !== undefined ? propCtaUrl : (section.props?.cta_url?.value ?? '');
return (
<>
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
<section
<div
id={section.id}
className={cn(
'wn-content relative w-full overflow-hidden',
finalHeightClasses,
'wn-content relative w-full',
scheme.text
)}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<SharedContentLayout
text={content}
textStyle={textStyle.style}
headingStyle={headingStyle.style}
containerWidth={containerWidth as any}
cardStyle={{ backgroundColor: section.styles?.cardBackgroundColor }}
className={contentStyle.classNames}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
@@ -234,7 +230,7 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
style: buttonStyle.style
}}
/>
</section>
</div>
</>
);
}

View File

@@ -32,16 +32,8 @@ export function FeatureGridSection({
features = [],
elementStyles,
styles,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
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-36',
'fullscreen': 'min-h-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
isEditor,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any>, isEditor?: boolean }) {
const safeItems = Array.isArray(items) ? items : [];
const safeFeatures = Array.isArray(features) ? features : [];
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
@@ -84,17 +76,14 @@ export function FeatureGridSection({
const sectionBg = getSectionBackground(styles);
return (
<section
<div
id={id}
className={cn(
'wn-section wn-feature-grid relative overflow-hidden w-full',
'wn-section wn-feature-grid relative w-full',
`wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`,
heightClasses
`wn-scheme--${colorScheme}`
)}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className="mx-auto px-4 relative z-10 w-full">
{heading && (
<h2
@@ -233,6 +222,6 @@ export function FeatureGridSection({
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
)}
</div>
</section>
</div>
);
}

View File

@@ -17,6 +17,7 @@ interface HeroSectionProps {
export function HeroSection({
id,
layout = 'default',
colorScheme,
title,
subtitle,
image,
@@ -24,16 +25,8 @@ export function HeroSection({
cta_url,
elementStyles,
styles,
}: HeroSectionProps & { styles?: Record<string, any> }) {
const heightMap: Record<string, string> = {
'default': 'py-16 md:py-28',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-16 md:py-28');
isEditor,
}: HeroSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
const isCentered = layout === 'centered' || layout === 'default';
@@ -84,6 +77,15 @@ export function HeroSection({
return undefined;
}; */
const colorSchemeClasses = {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
muted: 'bg-muted text-muted-foreground',
dark: 'bg-slate-900 text-white',
}[colorScheme || ''] || '';
const isBoxed = styles?.contentWidth === 'boxed';
return (
<section
id={id}
@@ -91,11 +93,9 @@ export function HeroSection({
'wn-section wn-hero',
`wn-hero--${layout}`,
'relative overflow-hidden w-full',
heightClasses,
!isBoxed && !sectionBg.style?.backgroundColor && !sectionBg.style?.backgroundImage && colorSchemeClasses
)}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className={cn(
'mx-auto z-10 relative flex w-full',
{
@@ -105,7 +105,11 @@ export function HeroSection({
)}>
{/* Image - Left */}
{image && isImageLeft && (
<div className="w-full md:w-1/2">
<div className={cn("w-full md:w-1/2 flex flex-col", {
'items-start': imageStyle.alignment === 'left',
'items-center': imageStyle.alignment === 'center',
'items-end': imageStyle.alignment === 'right',
})}>
<div
className={cn("shadow-xl overflow-hidden", !imageStyle.borderRadius && "rounded-lg")}
style={{
@@ -121,10 +125,11 @@ export function HeroSection({
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
className="w-full h-full object-cover"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
objectPosition: imageStyle.objectPosition,
height: imageStyle.height || 'auto',
}}
/>
</div>
@@ -169,9 +174,14 @@ export function HeroSection({
{/* Centered Image */}
<div
className={cn(
"mt-12 mx-auto shadow-xl overflow-hidden",
"mt-12 mx-auto shadow-xl overflow-hidden flex flex-col",
!imageStyle.borderRadius && "rounded-lg",
imageStyle.width ? "" : "max-w-4xl"
imageStyle.width ? "" : "max-w-4xl",
{
'mr-auto mx-0': imageStyle.alignment === 'left',
'ml-auto mx-0': imageStyle.alignment === 'right',
'mx-auto': imageStyle.alignment === 'center' || !imageStyle.alignment,
}
)}
style={{
backgroundColor: imageStyle.backgroundColor,
@@ -189,12 +199,13 @@ export function HeroSection({
alt={title || 'Hero image'}
className={cn(
"w-full rounded-[inherit]",
!imageStyle.height && "h-auto",
!imageStyle.objectFit && "object-cover"
!imageStyle.objectFit && "object-cover",
"h-full"
)}
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
objectPosition: imageStyle.objectPosition,
height: imageStyle.height || 'auto',
maxWidth: '100%',
}}
/>
@@ -202,24 +213,33 @@ export function HeroSection({
</div>
{cta_text && cta_url && (
<a
href={cta_url}
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>
<div className="w-full mt-8" style={{ textAlign: ctaStyle.style?.textAlign || (isCentered ? 'center' : 'left') as React.CSSProperties['textAlign'] }}>
<a
href={cta_url}
className={cn(
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors",
!ctaStyle.style?.backgroundColor && "bg-primary",
!ctaStyle.style?.color && "text-primary-foreground",
ctaStyle.classNames
)}
style={{
...ctaStyle.style,
textAlign: undefined
}}
>
{cta_text}
</a>
</div>
)}
</div>
{/* Image - Right */}
{image && isImageRight && (
<div className="w-full md:w-1/2">
<div className={cn("w-full md:w-1/2 flex flex-col", {
'items-start': imageStyle.alignment === 'left',
'items-center': imageStyle.alignment === 'center',
'items-end': imageStyle.alignment === 'right',
})}>
<div
className="rounded-lg shadow-xl overflow-hidden"
style={{
@@ -231,10 +251,11 @@ export function HeroSection({
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
className="w-full h-full object-cover"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
objectPosition: imageStyle.objectPosition,
height: imageStyle.height || 'auto',
}}
/>
</div>

View File

@@ -24,7 +24,8 @@ export function ImageTextSection({
cta_url,
elementStyles,
styles,
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
isEditor,
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string, isEditor?: boolean }) {
const isImageRight = layout === 'image-right' || layout === 'right';
// Helper to get text styles (including font family)
@@ -58,28 +59,14 @@ export function ImageTextSection({
const sectionBg = getSectionBackground(styles);
// 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',
'fullscreen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
return (
<section
<div
id={id}
className={cn(
'wn-section wn-image-text relative overflow-hidden w-full',
`wn-scheme--${colorScheme}`,
!styles?.paddingTop && !styles?.paddingBottom && heightClasses
'wn-section wn-image-text relative w-full',
`wn-scheme--${colorScheme}`
)}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<SharedContentLayout
title={title}
text={text}
@@ -90,16 +77,14 @@ export function ImageTextSection({
titleClassName={titleStyle.classNames}
textStyle={textStyle.style}
textClassName={textStyle.classNames}
imageStyle={{
backgroundColor: imageStyle.backgroundColor,
objectFit: imageStyle.objectFit,
}}
imageStyle={imageStyle}
cardStyle={{ backgroundColor: styles?.cardBackgroundColor }}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
classNames: buttonStyle.classNames,
style: buttonStyle.style
}}
/>
</section>
</div>
);
}

View File

@@ -9,6 +9,7 @@ interface MarqueeBannerProps {
speed?: number; // seconds for one full cycle
separator?: string;
styles?: Record<string, any>;
elementStyles?: Record<string, any>;
}
export function MarqueeBanner({
@@ -17,9 +18,30 @@ export function MarqueeBanner({
speed = 30,
separator = '✦',
styles,
elementStyles,
}: MarqueeBannerProps) {
const items = text.split(separator).map(t => t.trim()).filter(Boolean);
const getTextStyles = (elementName: string) => {
const es = elementStyles?.[elementName] || {};
return {
classNames: cn(
es.fontSize,
es.fontWeight,
{
'font-sans': es.fontFamily === 'secondary',
'font-serif': es.fontFamily === 'primary',
}
),
style: {
color: es.color,
textAlign: es.textAlign as any,
}
};
};
const textStyle = getTextStyles('text');
// If the parent didn't set a custom background, we fallback to primary for the marquee.
const hasCustomBg = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
@@ -31,12 +53,10 @@ export function MarqueeBanner({
id={id}
className={cn("wn-section wn-marquee relative overflow-hidden w-full", hasCustomPadding ? "" : "py-3")}
style={{
...sectionBg.style,
backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : sectionBg.style.backgroundColor,
backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : undefined,
color: !hasCustomBg ? '#fff' : 'inherit',
}}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className="flex whitespace-nowrap relative z-10">
{/* Duplicate twice for seamless infinite scroll */}
{[0, 1].map((i) => (
@@ -47,9 +67,13 @@ export function MarqueeBanner({
aria-hidden={i === 1}
>
{items.map((item, idx) => (
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
<span
key={idx}
className={cn("flex items-center gap-8 text-sm font-medium tracking-wide uppercase", textStyle.classNames)}
style={textStyle.style}
>
{item}
{idx < items.length - 1 && <span className="opacity-50 text-xs"></span>}
{idx < items.length - 1 && <span className="opacity-50 text-xs">{separator}</span>}
</span>
))}
</div>

View File

@@ -102,16 +102,14 @@ export function ProductCarousel({
<section
id={id}
className={cn("wn-section wn-product-carousel relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className="w-full mx-auto px-4 relative z-10">
{/* Header */}
<div className="flex items-end justify-between mb-8">
<div>
{title && (
<h2
className={cn("text-3xl md:text-4xl font-bold", titleStyle.classNames)}
className={cn("text-3xl font-bold", titleStyle.classNames)}
style={titleStyle.style}
>
{title}
@@ -124,8 +122,8 @@ export function ProductCarousel({
)}
</div>
<div className="flex items-center gap-3">
{cta_text && cta_url && (
<Link to={cta_url} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
{cta_text && (
<Link to={cta_url || '#'} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
{cta_text}
</Link>
)}

View File

@@ -54,6 +54,32 @@ export function ShoppableImage({
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
const hasImage = !!image;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const es = elementStyles?.[elementName] || {};
return {
classNames: cn(
es.fontSize,
es.fontWeight,
{
'font-sans': es.fontFamily === 'secondary',
'font-serif': es.fontFamily === 'primary',
}
),
style: {
color: es.color,
textAlign: es.textAlign as any,
backgroundColor: es.backgroundColor,
borderColor: es.borderColor,
borderWidth: es.borderWidth,
borderRadius: es.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const handleAddToCart = async (hotspot: Hotspot, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -88,19 +114,31 @@ export function ShoppableImage({
<section
id={id}
className={cn("wn-section wn-shoppable-image relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
style={sectionBg.style}
>
<SectionBackgroundRenderer bg={sectionBg} />
<div className="w-full mx-auto px-4 relative z-10">
{(title || subtitle) && (
<div className="mb-8 text-center">
{title && (
<h2 className="text-3xl md:text-4xl font-bold" style={{ color: elementStyles?.title?.color }}>
<h2
className={cn(
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
<p
className={cn(
"mt-2",
!elementStyles?.subtitle?.fontSize && "text-muted-foreground",
subtitleStyle.classNames
)}
style={subtitleStyle.style}
>
{subtitle}
</p>
)}
@@ -124,12 +162,14 @@ export function ShoppableImage({
{/* Hotspot pins */}
{displayHotspots.map((hotspot, idx) => {
const isActive = activeHotspot === idx;
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
return (
<div
key={idx}
className="absolute"
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
>
{/* Pulsing pin */}
<button
@@ -151,8 +191,8 @@ export function ShoppableImage({
<div
className={cn(
'absolute z-20 w-56 bg-white rounded-xl shadow-2xl border p-3',
hotspot.x > 60 ? 'right-full mr-3' : 'left-full ml-3',
hotspot.y > 60 ? 'bottom-0' : 'top-0',
xVal > 60 ? 'right-full mr-3' : 'left-full ml-3',
yVal > 60 ? 'bottom-0' : 'top-0',
)}
>
{/* Close */}