feat(pages): Page Editor module fixing and improvements

This commit is contained in:
Dwindi Ramadhana
2026-06-04 15:10:37 +07:00
parent fb1a6c40ef
commit 54a3a15f68
21 changed files with 489 additions and 368 deletions

View File

@@ -0,0 +1,26 @@
import { SectionStyleResult } from '@/lib/sectionStyles';
interface SectionBackgroundRendererProps {
bg: SectionStyleResult;
}
export function SectionBackgroundRenderer({ bg }: SectionBackgroundRendererProps) {
if (!bg.backgroundImage) return null;
return (
<div className="absolute inset-0 pointer-events-none z-0">
<img
src={bg.backgroundImage}
alt=""
role="presentation"
className="w-full h-full object-cover"
/>
{bg.hasOverlay && (
<div
className="absolute inset-0 bg-black"
style={{ opacity: bg.overlayOpacity }}
/>
)}
</div>
);
}

View File

@@ -207,10 +207,10 @@ export function CanvasRenderer({
> >
<div <div
className={cn( className={cn(
'bg-white transition-all duration-300 min-h-[500px]', 'bg-white transition-all duration-300 min-h-[500px] wn-page',
deviceMode === 'mobile' deviceMode === 'mobile'
? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden' ? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden'
: 'w-full h-full' : cn('h-full', containerWidth === 'boxed' ? 'container mx-auto max-w-6xl shadow-sm border-x border-b' : 'w-full')
)} )}
> >
{sections.length === 0 ? ( {sections.length === 0 ? (

View File

@@ -159,8 +159,8 @@ export function CanvasSection({
</div> </div>
) : ( ) : (
<div className={cn( <div className={cn(
"relative z-10", "relative z-10 w-full",
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full' section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : ''
)}> )}>
{children} {children}
</div> </div>

View File

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react'; import { Plus, Layout, Undo2, Redo2, Save, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
AlertDialog, AlertDialog,
@@ -50,6 +50,11 @@ export default function AppearancePages() {
setInspectorCollapsed, setInspectorCollapsed,
setAvailableSources, setAvailableSources,
setIsLoading, setIsLoading,
undo,
redo,
past,
future,
updateCurrentPage,
addSection, addSection,
deleteSection, deleteSection,
duplicateSection, duplicateSection,
@@ -61,6 +66,7 @@ export default function AppearancePages() {
updateSectionStyles, updateSectionStyles,
updateElementStyles, updateElementStyles,
markAsSaved, markAsSaved,
markAsChanged,
setAsSpaLanding, setAsSpaLanding,
unsetSpaLanding, unsetSpaLanding,
} = usePageEditorStore(); } = usePageEditorStore();
@@ -160,7 +166,10 @@ export default function AppearancePages() {
const endpoint = currentPage.type === 'page' const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}` ? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`; : `/templates/${currentPage.cpt}`;
return api.post(endpoint, { sections }); return api.post(endpoint, {
sections,
container_width: currentPage.containerWidth
});
}, },
onSuccess: () => { onSuccess: () => {
toast.success(__('Page saved successfully')); toast.success(__('Page saved successfully'));
@@ -332,17 +341,40 @@ export default function AppearancePages() {
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />} {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button> </Button>
{hasUnsavedChanges && ( {hasUnsavedChanges && (
<> <span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span> )}
<Button
variant="ghost" <div className="flex items-center rounded-md border bg-muted/50 p-0.5">
size="sm" <Button
onClick={handleDiscard} variant="ghost"
> size="icon"
<Undo2 className="w-4 h-4 mr-2" /> className="h-7 w-7 text-muted-foreground hover:text-foreground"
{__('Discard')} onClick={undo}
</Button> disabled={past.length === 0}
</> title={__('Undo')}
>
<Undo2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={redo}
disabled={future.length === 0}
title={__('Redo')}
>
<Redo2 className="w-4 h-4" />
</Button>
</div>
{hasUnsavedChanges && (
<Button
variant="ghost"
size="sm"
onClick={handleDiscard}
>
{__('Discard')}
</Button>
)} )}
<Button <Button
variant="outline" variant="outline"
@@ -455,10 +487,7 @@ export default function AppearancePages() {
onDeletePage={handleDeletePage} onDeletePage={handleDeletePage}
onDeleteTemplate={handleDeleteTemplate} onDeleteTemplate={handleDeleteTemplate}
onContainerWidthChange={(width) => { onContainerWidthChange={(width) => {
if (currentPage) { updateCurrentPage({ containerWidth: width });
setCurrentPage({ ...currentPage, containerWidth: width });
markAsSaved(); // Mark as changed so save button enables
}
}} }}
/> />
) )

View File

@@ -145,6 +145,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
stylableElements: [ stylableElements: [
{ name: 'heading', label: 'Heading', type: 'text' }, { name: 'heading', label: 'Heading', type: 'text' },
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' }, { name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
{ name: 'link', label: 'Link (Read more)', type: 'text' },
], ],
}, },
'cta-banner': { 'cta-banner': {
@@ -233,6 +234,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
stylableElements: [ stylableElements: [
{ name: 'title', label: 'Title', type: 'text' }, { name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' }, { name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'link', label: 'CTA Link', type: 'text' },
], ],
}, },
'shoppable-image': { 'shoppable-image': {

View File

@@ -73,6 +73,10 @@ export interface PageItem {
isSpaLanding?: boolean; isSpaLanding?: boolean;
containerWidth?: 'boxed' | 'fullwidth' | 'default'; containerWidth?: 'boxed' | 'fullwidth' | 'default';
} }
interface HistoryState {
sections: Section[];
currentPage: PageItem | null;
}
interface PageEditorState { interface PageEditorState {
// Current page/template being edited // Current page/template being edited
@@ -91,6 +95,10 @@ interface PageEditorState {
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
isLoading: boolean; isLoading: boolean;
// History (Undo/Redo)
past: HistoryState[];
future: HistoryState[];
// Available sources for dynamic fields (CPT templates) // Available sources for dynamic fields (CPT templates)
availableSources: { value: string; label: string }[]; availableSources: { value: string; label: string }[];
@@ -104,6 +112,14 @@ interface PageEditorState {
setAvailableSources: (sources: { value: string; label: string }[]) => void; setAvailableSources: (sources: { value: string; label: string }[]) => void;
setIsLoading: (loading: boolean) => void; setIsLoading: (loading: boolean) => void;
// History actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
// Page updates
updateCurrentPage: (updates: Partial<PageItem>) => void;
// Section actions // Section actions
addSection: (type: string, index?: number) => void; addSection: (type: string, index?: number) => void;
deleteSection: (id: string) => void; deleteSection: (id: string) => void;
@@ -137,11 +153,13 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
inspectorCollapsed: false, inspectorCollapsed: false,
hasUnsavedChanges: false, hasUnsavedChanges: false,
isLoading: false, isLoading: false,
past: [],
future: [],
availableSources: [], availableSources: [],
// Setters // Setters
setCurrentPage: (currentPage) => set({ currentPage }), setCurrentPage: (currentPage) => set({ currentPage }),
setSections: (sections) => set({ sections, hasUnsavedChanges: true }), setSections: (sections) => set({ sections, hasUnsavedChanges: true, past: [], future: [] }),
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }), setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }), setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
setDeviceMode: (deviceMode) => set({ deviceMode }), setDeviceMode: (deviceMode) => set({ deviceMode }),
@@ -149,9 +167,64 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
setAvailableSources: (availableSources) => set({ availableSources }), setAvailableSources: (availableSources) => set({ availableSources }),
setIsLoading: (isLoading) => set({ isLoading }), setIsLoading: (isLoading) => set({ isLoading }),
// History actions
pushHistory: () => {
const { sections, currentPage } = get();
set((state) => ({
past: [...state.past, {
sections: JSON.parse(JSON.stringify(sections)),
currentPage: JSON.parse(JSON.stringify(currentPage))
}],
future: []
}));
},
undo: () => {
const { past, future, sections, currentPage } = get();
if (past.length === 0) return;
const previous = past[past.length - 1];
const newPast = past.slice(0, past.length - 1);
set({
past: newPast,
future: [{ sections, currentPage }, ...future],
sections: previous.sections,
currentPage: previous.currentPage,
hasUnsavedChanges: true
});
},
redo: () => {
const { past, future, sections, currentPage } = get();
if (future.length === 0) return;
const next = future[0];
const newFuture = future.slice(1);
set({
past: [...past, { sections, currentPage }],
future: newFuture,
sections: next.sections,
currentPage: next.currentPage,
hasUnsavedChanges: true
});
},
// Page updates
updateCurrentPage: (updates) => {
const { currentPage, pushHistory } = get();
if (!currentPage) return;
pushHistory();
set({
currentPage: { ...currentPage, ...updates },
hasUnsavedChanges: true
});
},
// Section actions // Section actions
addSection: (type, index) => { addSection: (type, index) => {
const { sections } = get(); const { sections, pushHistory } = get();
const sectionConfig = getSectionSchema(type); const sectionConfig = getSectionSchema(type);
if (!sectionConfig) return; if (!sectionConfig) return;
@@ -163,6 +236,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
styles: cloneDefaultStyles(type) as SectionStyles, styles: cloneDefaultStyles(type) as SectionStyles,
}; };
pushHistory();
const newSections = [...sections]; const newSections = [...sections];
if (typeof index === 'number') { if (typeof index === 'number') {
newSections.splice(index, 0, newSection); newSections.splice(index, 0, newSection);
@@ -177,7 +252,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
}, },
deleteSection: (id) => { deleteSection: (id) => {
const { sections, selectedSectionId } = get(); const { sections, selectedSectionId, pushHistory } = get();
pushHistory();
const newSections = sections.filter(s => s.id !== id); const newSections = sections.filter(s => s.id !== id);
set({ set({
@@ -188,10 +264,12 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
}, },
duplicateSection: (id) => { duplicateSection: (id) => {
const { sections } = get(); const { sections, pushHistory } = get();
const index = sections.findIndex(s => s.id === id); const index = sections.findIndex(s => s.id === id);
if (index === -1) return; if (index === -1) return;
pushHistory();
const section = sections[index]; const section = sections[index];
const newSection: Section = { const newSection: Section = {
...JSON.parse(JSON.stringify(section)), // Deep clone ...JSON.parse(JSON.stringify(section)), // Deep clone
@@ -205,27 +283,32 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
}, },
moveSection: (id, direction) => { moveSection: (id, direction) => {
const { sections } = get(); const { sections, pushHistory } = get();
const index = sections.findIndex(s => s.id === id); const index = sections.findIndex(s => s.id === id);
if (index === -1) return; if (index === -1) return;
if (direction === 'up' && index > 0) { if (direction === 'up' && index > 0) {
pushHistory();
const newSections = [...sections]; const newSections = [...sections];
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]]; [newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true }); set({ sections: newSections, hasUnsavedChanges: true });
} else if (direction === 'down' && index < sections.length - 1) { } else if (direction === 'down' && index < sections.length - 1) {
pushHistory();
const newSections = [...sections]; const newSections = [...sections];
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]]; [newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true }); set({ sections: newSections, hasUnsavedChanges: true });
} }
}, },
reorderSections: (sections) => { reorderSections: (newSections) => {
set({ sections, hasUnsavedChanges: true }); const { sections, pushHistory } = get();
pushHistory();
set({ sections: newSections, hasUnsavedChanges: true });
}, },
updateSectionProp: (sectionId, propName, value) => { updateSectionProp: (sectionId, propName, value) => {
const { sections } = get(); const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => { const newSections = sections.map(section => {
if (section.id !== sectionId) return section; if (section.id !== sectionId) return section;
return { return {
@@ -240,7 +323,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
}, },
updateSectionLayout: (sectionId, layoutVariant) => { updateSectionLayout: (sectionId, layoutVariant) => {
const { sections } = get(); const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => { const newSections = sections.map(section => {
if (section.id !== sectionId) return section; if (section.id !== sectionId) return section;
return { return {
@@ -252,7 +336,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
}, },
updateSectionColorScheme: (sectionId, colorScheme) => { updateSectionColorScheme: (sectionId, colorScheme) => {
const { sections } = get(); const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => { const newSections = sections.map(section => {
if (section.id !== sectionId) return section; if (section.id !== sectionId) return section;
return { return {
@@ -264,7 +349,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
}, },
updateSectionStyles: (sectionId, styles) => { updateSectionStyles: (sectionId, styles) => {
const { sections } = get(); const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => { const newSections = sections.map(section => {
if (section.id !== sectionId) return section; if (section.id !== sectionId) return section;
return { return {
@@ -279,7 +365,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
}, },
updateElementStyles: (sectionId, fieldName, styles) => { updateElementStyles: (sectionId, fieldName, styles) => {
const { sections } = get(); const { sections, pushHistory } = get();
pushHistory();
const newSections = sections.map(section => { const newSections = sections.map(section => {
if (section.id !== sectionId) return section; if (section.id !== sectionId) return section;

View File

@@ -0,0 +1,26 @@
import { SectionStyleResult } from '@/lib/sectionStyles';
interface SectionBackgroundRendererProps {
bg: SectionStyleResult;
}
export function SectionBackgroundRenderer({ bg }: SectionBackgroundRendererProps) {
if (!bg.backgroundImage) return null;
return (
<div className="absolute inset-0 pointer-events-none z-0">
<img
src={bg.backgroundImage}
alt=""
role="presentation"
className="w-full h-full object-cover"
/>
{bg.hasOverlay && (
<div
className="absolute inset-0 bg-black"
style={{ opacity: bg.overlayOpacity }}
/>
)}
</div>
);
}

View File

@@ -53,19 +53,17 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
const isImageTop = imagePosition === 'top'; const isImageTop = imagePosition === 'top';
const isImageBottom = imagePosition === 'bottom'; const isImageBottom = imagePosition === 'bottom';
// Wrapper classes — full = edge-to-edge, contained = narrow readable column, boxed = card at max-w-5xl // Wrapper classes — no width constraints applied here, parent handles it
const containerClasses = cn( const containerClasses = cn(
'w-full mx-auto px-4 sm:px-6 lg:px-8', 'w-full mx-auto px-4 sm:px-6 lg:px-8',
containerWidth === 'contained' ? 'max-w-4xl' containerWidth === 'contained' ? 'max-w-4xl' : '' // only constraint needed is for contained narrow text
: containerWidth === 'boxed' ? 'max-w-5xl'
: '' // full = no max-width cap
); );
const gridClasses = cn( const gridClasses = cn(
'mx-auto', 'mx-auto w-full',
hasImage && (isImageLeft || isImageRight) hasImage && (isImageLeft || isImageRight)
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
: containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it : ''
); );
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first'; const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
@@ -78,161 +76,79 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
{containerWidth === 'boxed' ? ( <div className={gridClasses}>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10"> {/* Image Side */}
<div className={gridClasses}> {hasImage && (
{/* Image Side */} <div className={cn(
{hasImage && ( 'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
<div className={cn( imageWrapperOrder,
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg', (isImageTop || isImageBottom) && 'mb-8'
imageWrapperOrder, )} style={imageStyle}>
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked <img
)} style={imageStyle}> src={image}
<img alt={title || 'Section Image'}
src={image} className="absolute inset-0 w-full h-full object-cover"
alt={title || 'Section Image'} />
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div> </div>
</div> )}
) : (
<div className={gridClasses}> {/* Content Side */}
{/* Image Side */} <div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{hasImage && ( {title && (
<div className={cn( <h2
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg', className={cn(
imageWrapperOrder, "tracking-tight text-current mb-6 w-full",
(isImageTop || isImageBottom) && 'mb-8' !titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
)} style={imageStyle}> titleClassName
<img )}
src={image} style={titleStyle}
alt={title || 'Section Image'} >
className="absolute inset-0 w-full h-full object-cover" {title}
/> </h2>
</div>
)} )}
{/* Content Side */} {text && (
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}> <div
{title && ( className={cn(
<h2 'prose prose-lg max-w-none w-full',
className={cn( 'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
"tracking-tight text-current mb-6", 'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl", 'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
titleClassName 'prose-headings:text-[var(--tw-prose-headings)]',
)} 'prose-p:text-[var(--tw-prose-body)]',
style={titleStyle} 'text-[var(--tw-prose-body)]',
> className,
{title} textClassName
</h2> )}
)} style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{text && ( {/* Buttons */}
<div {buttons && buttons.length > 0 && (
className={cn( <div className="mt-8 flex flex-wrap gap-4">
'prose prose-lg max-w-none', {buttons.map((btn, idx) => (
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4', btn.text && btn.url && (
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3', <a
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2', key={idx}
'prose-headings:text-[var(--tw-prose-headings)]', href={btn.url}
'prose-p:text-[var(--tw-prose-body)]', className={cn(
'text-[var(--tw-prose-body)]', "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
className, !buttonStyle?.style?.backgroundColor && "bg-primary",
textClassName !buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
)} buttonStyle?.classNames
style={proseStyle} )}
dangerouslySetInnerHTML={{ __html: text }} style={buttonStyle?.style}
/> >
)} {btn.text}
</a>
{/* Buttons */} )
{buttons && buttons.length > 0 && ( ))}
<div className="mt-8 flex flex-wrap gap-4"> </div>
{buttons.map((btn, idx) => ( )}
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div> </div>
)} </div>
</div> </div>
); );
}; };

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { cn } from '@/lib/utils';
// Section Components // Section Components
import { HeroSection } from './sections/HeroSection'; import { HeroSection } from './sections/HeroSection';
@@ -30,7 +31,10 @@ interface SectionStyles {
backgroundOverlay?: number; backgroundOverlay?: number;
paddingTop?: string; paddingTop?: string;
paddingBottom?: string; paddingBottom?: string;
contentWidth?: 'full' | 'contained'; contentWidth?: 'full' | 'contained' | 'boxed';
gradientAngle?: number;
gradientFrom?: string;
gradientTo?: string;
} }
interface ElementStyle { interface ElementStyle {
@@ -266,15 +270,21 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
return ( return (
<div <div
key={section.id} key={section.id}
className="relative overflow-hidden" className={cn(
"relative overflow-hidden",
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
)}
style={{ style={{
// Only explicit custom padding overrides from the padding fields ...(section.styles?.backgroundType === 'gradient'
? { background: `linear-gradient(${section.styles?.gradientAngle ?? 135}deg, ${section.styles?.gradientFrom || '#9333ea'}, ${section.styles?.gradientTo || '#3b82f6'})` }
: { backgroundColor: section.styles?.backgroundColor }
),
paddingTop: section.styles?.paddingTop, paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom, paddingBottom: section.styles?.paddingBottom,
}} }}
> >
{/* Full-bleed background image & overlay */} {/* Full-bleed background image & overlay */}
{section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && ( {section.styles?.backgroundType === 'image' && section.styles?.backgroundImage && (
<> <>
<div <div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
@@ -286,19 +296,51 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
/> />
</> </>
)} )}
{/* Legacy: show bg image even without backgroundType set */}
{!section.styles?.backgroundType && 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 }}
/>
</>
)}
{/* Section component — manages its own background, height, and inner content width */} {/* Content Wrapper */}
<div className="relative z-10 w-full"> {section.styles?.contentWidth === 'boxed' ? (
<SectionComponent <div className="relative z-10 container mx-auto px-4 max-w-5xl">
id={section.id} <div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
section={section} <SectionComponent
layout={section.layoutVariant || 'default'} id={section.id}
colorScheme={section.colorScheme || 'default'} section={section}
styles={section.styles} layout={section.layoutVariant || 'default'}
elementStyles={section.elementStyles} colorScheme={section.colorScheme || 'default'}
{...flattenSectionProps(section.props || {})} styles={section.styles}
/> elementStyles={section.elementStyles}
</div> {...flattenSectionProps(section.props || {})}
/>
</div>
</div>
) : (
<div className={cn(
"relative z-10 w-full",
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : ''
)}>
<SectionComponent
id={section.id}
section={section}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}
elementStyles={section.elementStyles}
{...flattenSectionProps(section.props || {})}
/>
</div>
)}
</div> </div>
); );
})} })}

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface BentoItem { interface BentoItem {
label: string; label: string;
@@ -57,7 +58,6 @@ export function BentoCategoryGrid({
styles, styles,
elementStyles, elementStyles,
}: BentoCategoryGridProps) { }: BentoCategoryGridProps) {
const sectionBg = getSectionBackground(styles);
// Keep initial demo layout stable: merge configured items over demo items by index. // Keep initial demo layout stable: merge configured items over demo items by index.
// This prevents the preview grid from "collapsing" when the first item is added. // This prevents the preview grid from "collapsing" when the first item is added.
const displayItems: BentoItem[] = (() => { const displayItems: BentoItem[] = (() => {
@@ -69,13 +69,17 @@ export function BentoCategoryGrid({
}); });
})(); })();
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return ( return (
<section <section
id={id} id={id}
className="wn-section wn-bento-grid py-12 md:py-16" className={cn("wn-section wn-bento-grid relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
style={sectionBg.style} style={sectionBg.style}
> >
<div className="container mx-auto px-4 max-w-7xl"> <SectionBackgroundRenderer bg={sectionBg} />
<div className="w-full mx-auto px-4 relative z-10">
{title && ( {title && (
<h2 <h2
className="text-3xl md:text-4xl font-bold mb-8" className="text-3xl md:text-4xl font-bold mb-8"

View File

@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface CTABannerSectionProps { interface CTABannerSectionProps {
id: string; id: string;
@@ -65,7 +66,7 @@ export function CTABannerSection({
{title && ( {title && (
<h2 <h2
className={cn( className={cn(
"wn-cta__title mb-6", "wn-cta__title mb-6 w-full",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl", !elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
!elementStyles?.title?.fontWeight && "font-bold", !elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames titleStyle.classNames
@@ -78,7 +79,7 @@ export function CTABannerSection({
{text && ( {text && (
<p className={cn( <p className={cn(
'wn-cta-banner__text mb-8 max-w-2xl mx-auto', 'wn-cta-banner__text mb-8 max-w-2xl mx-auto w-full',
!elementStyles?.text?.fontSize && "text-lg md:text-xl", !elementStyles?.text?.fontSize && "text-lg md:text-xl",
styles?.contentWidth !== 'boxed' && { styles?.contentWidth !== 'boxed' && {
'text-white/90': colorScheme === 'primary', 'text-white/90': colorScheme === 'primary',
@@ -120,51 +121,23 @@ export function CTABannerSection({
</> </>
); );
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles); const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return ( return (
<section <section
id={id} id={id}
className={cn( className={cn(
'wn-section wn-cta-banner', 'wn-section wn-cta-banner relative overflow-hidden w-full',
`wn-cta-banner--${layout}`, `wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
heightClasses, heightClasses
{
'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
)} )}
style={getBackgroundStyle()} style={sectionBg.style}
> >
{styles?.contentWidth === 'boxed' ? ( <SectionBackgroundRenderer bg={sectionBg} />
<div className="container mx-auto px-4 max-w-5xl"> <div className="mx-auto px-4 text-center relative z-10 w-full">
<div className="bg-white text-gray-900 rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10 text-center"> {innerContent}
{innerContent} </div>
</div>
</div>
) : (
<div className={cn(
"mx-auto px-4 text-center",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
{innerContent}
</div>
)}
</section> </section>
); );
} }

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface ContactFormSectionProps { interface ContactFormSectionProps {
id: string; id: string;
@@ -97,40 +98,22 @@ export function ContactFormSection({
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
}; };
const sectionBg = getSectionBackground(styles);
return ( return (
<section <section
id={id} id={id}
className={cn( className={cn(
'wn-section wn-contact-form', 'wn-section wn-contact-form relative overflow-hidden w-full',
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
heightClasses, heightClasses
{
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
)} )}
style={getBackgroundStyle()} style={sectionBg.style}
> >
<div className={cn( <SectionBackgroundRenderer bg={sectionBg} />
"mx-auto px-4", <div className="mx-auto px-4 relative z-10 w-full">
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container'
)}>
<div className={cn( <div className={cn(
'max-w-xl mx-auto', 'max-w-xl mx-auto',
{ {
@@ -139,7 +122,7 @@ export function ContactFormSection({
)}> )}>
{title && ( {title && (
<h2 className={cn( <h2 className={cn(
"wn-contact__title text-center mb-12", "wn-contact__title text-center mb-12 w-full",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl", !elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
!elementStyles?.title?.fontWeight && "font-bold", !elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames titleStyle.classNames

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout'; import { SharedContentLayout } from '@/components/SharedContentLayout';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface ContentSectionProps { interface ContentSectionProps {
id?: string; id?: string;
@@ -170,10 +171,12 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
'small': 'py-8 md:py-12', 'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24', 'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32', 'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center', 'fullscreen': 'min-h-screen py-20 flex items-center',
}; };
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20'; 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 || section.props?.content?.value || section.props?.content || '';
@@ -206,34 +209,19 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text; 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_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(section.styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
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 ( return (
<> <>
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} /> <style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
<section <section
id={section.id} id={section.id}
className={cn( className={cn(
'wn-content', 'wn-content relative w-full overflow-hidden',
heightClasses, finalHeightClasses,
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text scheme.text
)} )}
style={getBackgroundStyle()} style={sectionBg.style}
> >
<SectionBackgroundRenderer bg={sectionBg} />
<SharedContentLayout <SharedContentLayout
text={content} text={content}
textStyle={textStyle.style} textStyle={textStyle.style}

View File

@@ -1,6 +1,7 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface FeatureItem { interface FeatureItem {
title?: string; title?: string;
@@ -78,43 +79,23 @@ export function FeatureGridSection({
const headingStyle = getTextStyles('heading'); const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item'); const featureItemStyle = getTextStyles('feature_item');
const linkStyle = getTextStyles('link');
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles); const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return ( return (
<section <section
id={id} id={id}
className={cn( className={cn(
'wn-section wn-feature-grid', 'wn-section wn-feature-grid relative overflow-hidden w-full',
`wn-feature-grid--${layout}`, `wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
heightClasses, heightClasses
{
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
}
)} )}
style={getBackgroundStyle()} style={sectionBg.style}
> >
<div className={cn( <SectionBackgroundRenderer bg={sectionBg} />
"mx-auto px-4", <div className="mx-auto px-4 relative z-10 w-full">
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container'
)}>
{heading && ( {heading && (
<h2 <h2
className={cn( className={cn(
@@ -167,16 +148,22 @@ export function FeatureGridSection({
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p> <p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
)} )}
{item.title && ( {item.title && (
<h3 className="font-semibold text-gray-900 text-base leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2"> <h3 className={cn("font-semibold text-gray-900 leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2 w-full",
!elementStyles?.feature_item?.fontSize && "text-base",
featureItemStyle.classNames
)} style={featureItemStyle.style}>
{item.title} {item.title}
</h3> </h3>
)} )}
{(item.excerpt || item.description) && ( {(item.excerpt || item.description) && (
<p className="text-sm text-gray-500 line-clamp-3 mb-4"> <p className={cn("text-gray-500 line-clamp-3 mb-4 w-full",
!elementStyles?.feature_item?.fontSize && "text-sm",
featureItemStyle.classNames
)} style={featureItemStyle.style}>
{item.excerpt || item.description} {item.excerpt || item.description}
</p> </p>
)} )}
<span className="inline-flex items-center gap-1 text-sm font-medium text-primary"> <span className={cn("inline-flex items-center gap-1 text-sm font-medium text-primary", linkStyle.classNames)} style={linkStyle.style}>
Read more Read more
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />

View File

@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface HeroSectionProps { interface HeroSectionProps {
id: string; id: string;
@@ -89,16 +90,14 @@ export function HeroSection({
className={cn( className={cn(
'wn-section wn-hero', 'wn-section wn-hero',
`wn-hero--${layout}`, `wn-hero--${layout}`,
'relative overflow-hidden', 'relative overflow-hidden w-full',
heightClasses, heightClasses,
)} )}
style={sectionBg.style} style={sectionBg.style}
> >
<SectionBackgroundRenderer bg={sectionBg} />
<div className={cn( <div className={cn(
'mx-auto px-4 z-10 relative flex w-full', 'mx-auto z-10 relative flex w-full',
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container max-w-7xl',
{ {
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight, 'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
'text-center': isCentered, 'text-center': isCentered,
@@ -108,11 +107,15 @@ export function HeroSection({
{image && isImageLeft && ( {image && isImageLeft && (
<div className="w-full md:w-1/2"> <div className="w-full md:w-1/2">
<div <div
className="rounded-lg shadow-xl overflow-hidden" className={cn("shadow-xl overflow-hidden", !imageStyle.borderRadius && "rounded-lg")}
style={{ style={{
backgroundColor: imageStyle.backgroundColor, backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto', width: imageStyle.width || 'auto',
maxWidth: '100%' maxWidth: '100%',
borderRadius: imageStyle.borderRadius,
borderColor: imageStyle.borderColor,
borderWidth: imageStyle.borderWidth,
borderStyle: imageStyle.borderWidth ? 'solid' : undefined,
}} }}
> >
<img <img
@@ -139,7 +142,7 @@ export function HeroSection({
{title && ( {title && (
<h1 <h1
className={cn( className={cn(
"wn-hero__title mb-6 leading-tight", "wn-hero__title mb-6 leading-tight w-full",
!elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl", !elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
!elementStyles?.title?.fontWeight && "font-bold", !elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames titleStyle.classNames
@@ -153,7 +156,7 @@ export function HeroSection({
{subtitle && ( {subtitle && (
<p <p
className={cn( className={cn(
"wn-hero__subtitle text-opacity-80 mb-8", "wn-hero__subtitle text-opacity-80 mb-8 w-full",
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl", !elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
subtitleStyle.classNames subtitleStyle.classNames
)} )}
@@ -166,10 +169,19 @@ export function HeroSection({
{/* Centered Image */} {/* Centered Image */}
<div <div
className={cn( className={cn(
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden", "mt-12 mx-auto shadow-xl overflow-hidden",
!imageStyle.borderRadius && "rounded-lg",
imageStyle.width ? "" : "max-w-4xl" imageStyle.width ? "" : "max-w-4xl"
)} )}
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }} style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%',
borderRadius: imageStyle.borderRadius,
borderColor: imageStyle.borderColor,
borderWidth: imageStyle.borderWidth,
borderStyle: imageStyle.borderWidth ? 'solid' : undefined,
}}
> >
{image && isCentered && ( {image && isCentered && (
<img <img

View File

@@ -1,6 +1,7 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout'; import { SharedContentLayout } from '@/components/SharedContentLayout';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface ImageTextSectionProps { interface ImageTextSectionProps {
id: string; id: string;
@@ -55,6 +56,8 @@ export function ImageTextSection({
const imageStyle = elementStyles?.['image'] || {}; const imageStyle = elementStyles?.['image'] || {};
const sectionBg = getSectionBackground(styles);
// Height preset support // Height preset support
const heightPreset = styles?.heightPreset || 'default'; const heightPreset = styles?.heightPreset || 'default';
const heightMap: Record<string, string> = { const heightMap: Record<string, string> = {
@@ -62,38 +65,21 @@ export function ImageTextSection({
'small': 'py-8 md:py-16', 'small': 'py-8 md:py-16',
'medium': 'py-16 md:py-32', 'medium': 'py-16 md:py-32',
'large': 'py-24 md:py-48', 'large': 'py-24 md:py-48',
'screen': 'min-h-screen py-20 flex items-center', 'fullscreen': 'min-h-screen py-20 flex items-center',
}; };
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24'; const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return ( return (
<section <section
id={id} id={id}
className={cn( className={cn(
'wn-section wn-image-text', 'wn-section wn-image-text relative overflow-hidden w-full',
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
!styles?.paddingTop && !styles?.paddingBottom && heightClasses, !styles?.paddingTop && !styles?.paddingBottom && heightClasses
{
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
)} )}
style={getBackgroundStyle()} style={sectionBg.style}
> >
<SectionBackgroundRenderer bg={sectionBg} />
<SharedContentLayout <SharedContentLayout
title={title} title={title}
text={text} text={text}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
interface MarqueeBannerProps { interface MarqueeBannerProps {
id: string; id: string;
@@ -17,19 +18,26 @@ export function MarqueeBanner({
separator = '✦', separator = '✦',
styles, styles,
}: MarqueeBannerProps) { }: MarqueeBannerProps) {
const sectionBg = getSectionBackground(styles);
const items = text.split(separator).map(t => t.trim()).filter(Boolean); const items = text.split(separator).map(t => t.trim()).filter(Boolean);
// 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';
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return ( return (
<section <section
id={id} id={id}
className="wn-section wn-marquee overflow-hidden py-3" className={cn("wn-section wn-marquee relative overflow-hidden w-full", hasCustomPadding ? "" : "py-3")}
style={{ style={{
backgroundColor: sectionBg.style?.backgroundColor || 'var(--wn-primary, #1a1a1a)', ...sectionBg.style,
color: sectionBg.style?.color || '#fff', backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : sectionBg.style.backgroundColor,
color: !hasCustomBg ? '#fff' : 'inherit',
}} }}
> >
<div className="flex whitespace-nowrap"> <SectionBackgroundRenderer bg={sectionBg} />
<div className="flex whitespace-nowrap relative z-10">
{/* Duplicate twice for seamless infinite scroll */} {/* Duplicate twice for seamless infinite scroll */}
{[0, 1].map((i) => ( {[0, 1].map((i) => (
<div <div

View File

@@ -6,6 +6,7 @@ import { apiClient } from '@/lib/api/client';
import { ProductCard } from '@/components/ProductCard'; import { ProductCard } from '@/components/ProductCard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
import type { ProductsResponse } from '@/types/product'; import type { ProductsResponse } from '@/types/product';
interface ProductCarouselProps { interface ProductCarouselProps {
@@ -39,7 +40,6 @@ export function ProductCarousel({
elementStyles, elementStyles,
}: ProductCarouselProps) { }: ProductCarouselProps) {
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
const sectionBg = getSectionBackground(styles);
// Build query params // Build query params
const queryParams = new URLSearchParams({ per_page: String(limit) }); const queryParams = new URLSearchParams({ per_page: String(limit) });
@@ -68,29 +68,64 @@ export function ProductCarousel({
trackRef.current.scrollBy({ left: direction === 'left' ? -cardWidth * 2 : cardWidth * 2, behavior: 'smooth' }); trackRef.current.scrollBy({ left: direction === 'left' ? -cardWidth * 2 : cardWidth * 2, behavior: 'smooth' });
}; };
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const elementStyle = elementStyles?.[elementName] || {};
return {
classNames: cn(
elementStyle.fontSize,
elementStyle.fontWeight,
{
'font-sans': elementStyle.fontFamily === 'secondary',
'font-serif': elementStyle.fontFamily === 'primary',
}
),
style: {
color: elementStyle.color,
textAlign: elementStyle.textAlign,
backgroundColor: elementStyle.backgroundColor,
borderColor: elementStyle.borderColor,
borderWidth: elementStyle.borderWidth,
borderRadius: elementStyle.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const linkStyle = getTextStyles('link');
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return ( return (
<section id={id} className="wn-section wn-product-carousel py-12 md:py-16" style={sectionBg.style}> <section
<div className="container mx-auto px-4 max-w-7xl"> 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 */} {/* Header */}
<div className="flex items-end justify-between mb-8"> <div className="flex items-end justify-between mb-8">
<div> <div>
{title && ( {title && (
<h2 <h2
className="text-3xl md:text-4xl font-bold" className={cn("text-3xl md:text-4xl font-bold", titleStyle.classNames)}
style={{ color: elementStyles?.title?.color }} style={titleStyle.style}
> >
{title} {title}
</h2> </h2>
)} )}
{subtitle && ( {subtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}> <p className={cn("text-muted-foreground mt-2", subtitleStyle.classNames)} style={subtitleStyle.style}>
{subtitle} {subtitle}
</p> </p>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{cta_text && cta_url && ( {cta_text && cta_url && (
<Link to={cta_url} className="text-sm font-semibold hover:underline mr-4 whitespace-nowrap"> <Link to={cta_url} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
{cta_text} {cta_text}
</Link> </Link>
)} )}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { X, ShoppingCart, Eye } from 'lucide-react'; import { X, ShoppingCart, Eye } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles'; import { getSectionBackground } from '@/lib/sectionStyles';
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import { useCartStore } from '@/lib/cart/store'; import { useCartStore } from '@/lib/cart/store';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
@@ -47,7 +48,6 @@ export function ShoppableImage({
styles, styles,
elementStyles, elementStyles,
}: ShoppableImageProps) { }: ShoppableImageProps) {
const sectionBg = getSectionBackground(styles);
const [activeHotspot, setActiveHotspot] = useState<number | null>(null); const [activeHotspot, setActiveHotspot] = useState<number | null>(null);
const { addItem, openCart } = useCartStore(); const { addItem, openCart } = useCartStore();
@@ -81,9 +81,17 @@ export function ShoppableImage({
} }
}; };
const sectionBg = getSectionBackground(styles);
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
return ( return (
<section id={id} className="wn-section wn-shoppable-image py-12 md:py-16" style={sectionBg.style}> <section
<div className="container mx-auto px-4 max-w-7xl"> 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) && ( {(title || subtitle) && (
<div className="mb-8 text-center"> <div className="mb-8 text-center">
{title && ( {title && (

View File

@@ -2,6 +2,11 @@
module.exports = { module.exports = {
darkMode: ["class"], darkMode: ["class"],
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"], content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
safelist: [
// Dynamic typography classes selected via Inspector Panel
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', 'text-6xl',
'font-light', 'font-normal', 'font-medium', 'font-semibold', 'font-bold', 'font-extrabold'
],
theme: { theme: {
container: { center: true, padding: "1rem" }, container: { center: true, padding: "1rem" },
extend: { extend: {

View File

@@ -446,11 +446,15 @@ class PagesController
$rendered_sections = self::resolve_sections_for_post($template['sections'], $post, $type); $rendered_sections = self::resolve_sections_for_post($template['sections'], $post, $type);
} }
// Get SPA settings
$settings = get_option('woonoow_appearance_settings', []);
return new WP_REST_Response([ return new WP_REST_Response([
'type' => 'content', 'type' => 'content',
'cpt' => $type, 'cpt' => $type,
'post' => $post_data, 'post' => $post_data,
'seo' => $seo, 'seo' => $seo,
'effective_container_width' => ($settings['general']['container_width'] ?? 'boxed') ?: 'boxed',
'template' => $template ?: ['sections' => []], 'template' => $template ?: ['sections' => []],
'rendered' => [ 'rendered' => [
'sections' => $rendered_sections, 'sections' => $rendered_sections,