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

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

View File

@@ -0,0 +1,278 @@
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { cn } from '@/lib/utils';
import { Plus, Monitor, Smartphone, LayoutTemplate } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CanvasSection } from './CanvasSection';
import {
HeroRenderer,
ContentRenderer,
ImageTextRenderer,
FeatureGridRenderer,
CTABannerRenderer,
ContactFormRenderer,
} from './section-renderers';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
}
interface CanvasRendererProps {
sections: Section[];
selectedSectionId: string | null;
deviceMode: 'desktop' | 'mobile';
onSelectSection: (id: string | null) => void;
onAddSection: (type: string, index?: number) => void;
onDeleteSection: (id: string) => void;
onDuplicateSection: (id: string) => void;
onMoveSection: (id: string, direction: 'up' | 'down') => void;
onReorderSections: (sections: Section[]) => void;
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
}
const SECTION_TYPES = [
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
{ type: 'content', label: 'Content', icon: LayoutTemplate },
{ type: 'image-text', label: 'Image + Text', icon: LayoutTemplate },
{ type: 'feature-grid', label: 'Feature Grid', icon: LayoutTemplate },
{ type: 'cta-banner', label: 'CTA Banner', icon: LayoutTemplate },
{ type: 'contact-form', label: 'Contact Form', icon: LayoutTemplate },
];
// Map section type to renderer component
const SECTION_RENDERERS: Record<string, React.FC<{ section: Section; className?: string }>> = {
'hero': HeroRenderer,
'content': ContentRenderer,
'image-text': ImageTextRenderer,
'feature-grid': FeatureGridRenderer,
'cta-banner': CTABannerRenderer,
'contact-form': ContactFormRenderer,
};
export function CanvasRenderer({
sections,
selectedSectionId,
deviceMode,
onSelectSection,
onAddSection,
onDeleteSection,
onDuplicateSection,
onMoveSection,
onReorderSections,
onDeviceModeChange,
}: CanvasRendererProps) {
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = sections.findIndex(s => s.id === active.id);
const newIndex = sections.findIndex(s => s.id === over.id);
const newSections = arrayMove(sections, oldIndex, newIndex);
onReorderSections(newSections);
}
};
const handleCanvasClick = (e: React.MouseEvent) => {
// Only deselect if clicking directly on canvas background
if (e.target === e.currentTarget) {
onSelectSection(null);
}
};
return (
<div className="flex-1 flex flex-col bg-gray-100 overflow-hidden">
{/* Device mode toggle */}
<div className="flex items-center justify-center gap-2 py-3 bg-white border-b">
<Button
variant={deviceMode === 'desktop' ? 'default' : 'ghost'}
size="sm"
onClick={() => onDeviceModeChange('desktop')}
className="gap-2"
>
<Monitor className="w-4 h-4" />
Desktop
</Button>
<Button
variant={deviceMode === 'mobile' ? 'default' : 'ghost'}
size="sm"
onClick={() => onDeviceModeChange('mobile')}
className="gap-2"
>
<Smartphone className="w-4 h-4" />
Mobile
</Button>
</div>
{/* Canvas viewport */}
<div
className="flex-1 overflow-y-auto p-6"
onClick={handleCanvasClick}
>
<div
className={cn(
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
deviceMode === 'desktop' ? 'max-w-4xl' : 'max-w-sm'
)}
>
{sections.length === 0 ? (
<div className="py-24 text-center text-gray-400">
<LayoutTemplate className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No sections yet</p>
<p className="text-sm mb-6">Add your first section to start building</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
Add Section
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{SECTION_TYPES.map((type) => (
<DropdownMenuItem
key={type.type}
onClick={() => onAddSection(type.type)}
>
<type.icon className="w-4 h-4 mr-2" />
{type.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="flex flex-col">
{/* Top Insertion Zone */}
<InsertionZone
index={0}
onAdd={(type) => onAddSection(type)} // Implicitly index 0 is fine if we handle it in store, but wait store expects index.
// Actually onAddSection in Props is (type) => void. I need to update Props too.
// Let's check props interface above.
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sections.map(s => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col">
{sections.map((section, index) => {
const Renderer = SECTION_RENDERERS[section.type];
return (
<React.Fragment key={section.id}>
<CanvasSection
section={section}
isSelected={selectedSectionId === section.id}
isHovered={hoveredSectionId === section.id}
onSelect={() => onSelectSection(section.id)}
onHover={() => setHoveredSectionId(section.id)}
onLeave={() => setHoveredSectionId(null)}
onDelete={() => onDeleteSection(section.id)}
onDuplicate={() => onDuplicateSection(section.id)}
onMoveUp={() => onMoveSection(section.id, 'up')}
onMoveDown={() => onMoveSection(section.id, 'down')}
canMoveUp={index > 0}
canMoveDown={index < sections.length - 1}
>
{Renderer ? (
<Renderer section={section} />
) : (
<div className="p-8 text-center text-gray-400">
Unknown section type: {section.type}
</div>
)}
</CanvasSection>
{/* Insertion Zone After Section */}
<InsertionZone
index={index + 1}
onAdd={(type) => onAddSection(type, index + 1)}
/>
</React.Fragment>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
</div>
</div>
</div>
);
}
// Helper: Insertion Zone Component
function InsertionZone({ index, onAdd }: { index: number; onAdd: (type: string) => void }) {
return (
<div className="group relative h-4 -my-2 z-10 flex items-center justify-center transition-all hover:h-8 hover:my-0">
{/* Line */}
<div className="absolute left-4 right-4 h-0.5 bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity" />
{/* Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="relative z-10 w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all hover:scale-110 shadow-sm"
title="Add Section Here"
>
<Plus className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{SECTION_TYPES.map((type) => (
<DropdownMenuItem
key={type.type}
onClick={() => onAdd(type.type)}
>
<type.icon className="w-4 h-4 mr-2" />
{type.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,221 @@
import React, { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { GripVertical, Trash2, Copy, ChevronUp, ChevronDown } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { __ } from '@/lib/i18n';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Section } from '../store/usePageEditorStore';
interface CanvasSectionProps {
section: Section;
children: ReactNode;
isSelected: boolean;
isHovered: boolean;
onSelect: () => void;
onHover: () => void;
onLeave: () => void;
onDelete: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
}
export function CanvasSection({
section,
children,
isSelected,
isHovered,
onSelect,
onHover,
onLeave,
onDelete,
onDuplicate,
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
}: CanvasSectionProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'relative group transition-all duration-200',
isDragging && 'opacity-50 z-50',
isSelected && 'ring-2 ring-blue-500 ring-offset-2',
isHovered && !isSelected && 'ring-1 ring-blue-300'
)}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onMouseEnter={onHover}
onMouseLeave={onLeave}
>
{/* Section content with Styles */}
<div
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && "bg-white/50")}
style={{
backgroundColor: section.styles?.backgroundColor,
paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom,
}}
>
{/* Background Image & Overlay */}
{section.styles?.backgroundImage && (
<>
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
/>
<div
className="absolute inset-0 z-0 bg-black"
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
/>
</>
)}
{/* Content Wrapper */}
<div className={cn(
"relative z-10",
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
)}>
{children}
</div>
</div>
{/* Floating Toolbar (Standard Interaction) */}
{isSelected && (
<div className="absolute -top-10 right-0 z-50 flex items-center gap-1 bg-white shadow-lg border rounded-lg px-2 py-1 animate-in fade-in slide-in-from-bottom-2">
{/* Label */}
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide mr-2 px-1">
{section.type.replace('-', ' ')}
</span>
{/* Divider */}
<div className="w-px h-4 bg-gray-200 mx-1" />
{/* Actions */}
<button
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
disabled={!canMoveUp}
className={cn(
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
!canMoveUp && 'opacity-30 cursor-not-allowed'
)}
title="Move up"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
disabled={!canMoveDown}
className={cn(
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
!canMoveDown && 'opacity-30 cursor-not-allowed'
)}
title="Move down"
>
<ChevronDown className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition"
title="Duplicate"
>
<Copy className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-gray-200 mx-1" />
<div className="w-px h-4 bg-gray-200 mx-1" />
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent className="z-[60]">
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
<AlertDialogDescription>
{__('This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{/* Active Border Label */}
{isSelected && (
<div className="absolute -top-px left-0 bg-blue-500 text-white text-[10px] uppercase font-bold px-2 py-0.5 rounded-b-sm z-10">
{section.type}
</div>
)}
{/* Drag Handle (Always visible on hover or select) */}
{(isSelected || isHovered) && (
<button
{...attributes}
{...listeners}
className="absolute top-1/2 -left-8 -translate-y-1/2 p-1.5 rounded text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing hover:bg-gray-100"
title="Drag to reorder"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="w-5 h-5" />
</button>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
@@ -35,6 +35,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
const [pageType, setPageType] = useState<'page' | 'template'>('page');
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
// Prevent double submission
const isSubmittingRef = useRef(false);
@@ -42,9 +43,18 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
// Get site URL from WordPress config
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
// Fetch templates
const { data: templates = [] } = useQuery({
queryKey: ['templates-presets'],
queryFn: async () => {
const res = await api.get('/templates/presets');
return res as { id: string; label: string; description: string; icon: string }[];
}
});
// Create page mutation
const createMutation = useMutation({
mutationFn: async (data: { title: string; slug: string }) => {
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
// Guard against double submission
if (isSubmittingRef.current) {
throw new Error('Request already in progress');
@@ -53,7 +63,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
try {
// api.post returns JSON directly (not wrapped in { data: ... })
const response = await api.post('/pages', { title: data.title, slug: data.slug });
const response = await api.post('/pages', {
title: data.title,
slug: data.slug,
templateId: data.templateId
});
return response; // Return response directly, not response.data
} finally {
// Reset after a delay to prevent race conditions
@@ -74,6 +88,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
onOpenChange(false);
setTitle('');
setSlug('');
setSelectedTemplateId('blank');
}
},
onError: (error: any) => {
@@ -105,7 +120,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
return;
}
if (pageType === 'page' && title && slug) {
createMutation.mutate({ title, slug });
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
}
};
@@ -115,6 +130,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
setTitle('');
setSlug('');
setPageType('page');
setSelectedTemplateId('blank');
isSubmittingRef.current = false;
}
}, [open]);
@@ -123,7 +139,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{__('Create New Page')}</DialogTitle>
<DialogDescription>
@@ -133,8 +149,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
<div className="space-y-6 py-4 px-1">
{/* Page Type Selection */}
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')}>
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer hover:bg-accent/50 transition-colors">
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
<div
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
onClick={() => setPageType('page')}
>
<RadioGroupItem value="page" id="page" className="mt-1" />
<div className="flex-1">
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
@@ -147,7 +166,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
</div>
</div>
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50">
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
<div className="flex-1">
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
@@ -163,30 +182,59 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
{/* Page Details */}
{pageType === 'page' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{__('Page Title')}</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={__('e.g., About Us')}
disabled={createMutation.isPending}
/>
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">{__('Page Title')}</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={__('e.g., About Us')}
disabled={createMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">{__('URL Slug')}</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder={__('e.g., about-us')}
disabled={createMutation.isPending}
/>
<p className="text-xs text-muted-foreground truncate">
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="slug">{__('URL Slug')}</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder={__('e.g., about-us')}
disabled={createMutation.isPending}
/>
<p className="text-xs text-muted-foreground">
{__('URL will be: ')}<span className="font-mono text-primary">{siteUrl}/{slug || 'page-slug'}</span>
</p>
<div className="space-y-3">
<Label>{__('Choose a Template')}</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{templates.map((tpl) => (
<div
key={tpl.id}
className={`
relative p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
${selectedTemplateId === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
`}
onClick={() => setSelectedTemplateId(tpl.id)}
>
<div className="mb-2 font-medium text-sm flex items-center gap-2">
{tpl.label}
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{tpl.description}
</p>
</div>
))}
{templates.length === 0 && (
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
{__('Loading templates...')}
</div>
)}
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { __ } from '@/lib/i18n';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MediaUploader } from '@/components/MediaUploader';
import { Image as ImageIcon } from 'lucide-react';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
export interface SectionProp {
type: 'static' | 'dynamic';
value?: any;
source?: string;
}
interface InspectorFieldProps {
fieldName: string;
fieldLabel: string;
fieldType: 'text' | 'textarea' | 'url' | 'image' | 'rte';
value: SectionProp;
onChange: (value: SectionProp) => void;
supportsDynamic?: boolean;
availableSources?: { value: string; label: string }[];
}
export function InspectorField({
fieldName,
fieldLabel,
fieldType,
value,
onChange,
supportsDynamic = false,
availableSources = [],
}: InspectorFieldProps) {
const isDynamic = value.type === 'dynamic';
const currentValue = isDynamic ? (value.source || '') : (value.value || '');
const handleValueChange = (newValue: string) => {
if (isDynamic) {
onChange({ type: 'dynamic', source: newValue });
} else {
onChange({ type: 'static', value: newValue });
}
};
const handleTypeToggle = (dynamic: boolean) => {
if (dynamic) {
onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' });
} else {
onChange({ type: 'static', value: '' });
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={fieldName} className="text-sm font-medium">
{fieldLabel}
</Label>
{supportsDynamic && availableSources.length > 0 && (
<div className="flex items-center gap-2">
<span className={cn(
'text-xs',
isDynamic ? 'text-orange-500 font-medium' : 'text-gray-400'
)}>
{isDynamic ? '◆ Dynamic' : 'Static'}
</span>
<Switch
checked={isDynamic}
onCheckedChange={handleTypeToggle}
/>
</div>
)}
</div>
{isDynamic && supportsDynamic ? (
<Select value={currentValue} onValueChange={handleValueChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select data source" />
</SelectTrigger>
<SelectContent>
{availableSources.map((source) => (
<SelectItem key={source.value} value={source.value}>
{source.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : fieldType === 'rte' ? (
<RichTextEditor
content={currentValue}
onChange={handleValueChange}
placeholder={`Enter ${fieldLabel.toLowerCase()}...`}
/>
) : fieldType === 'textarea' ? (
<Textarea
id={fieldName}
value={currentValue}
onChange={(e) => handleValueChange(e.target.value)}
rows={4}
className="resize-none"
/>
) : (
<div className="flex gap-2">
<Input
id={fieldName}
type={fieldType === 'url' ? 'url' : 'text'}
value={currentValue}
onChange={(e) => handleValueChange(e.target.value)}
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
className="flex-1"
/>
{(fieldType === 'url' || fieldType === 'image') && (
<MediaUploader
onSelect={(url) => handleValueChange(url)}
type="image"
className="shrink-0"
>
<Button variant="outline" size="icon" title={__('Select Image')}>
<ImageIcon className="w-4 h-4" />
</Button>
</MediaUploader>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,779 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Slider } from '@/components/ui/slider';
import {
Settings,
PanelRightClose,
PanelRight,
Trash2,
ExternalLink,
Palette,
Type,
Home
} from 'lucide-react';
import { InspectorField, SectionProp } from './InspectorField';
import { InspectorRepeater } from './InspectorRepeater';
import { MediaUploader } from '@/components/MediaUploader';
import { SectionStyles, ElementStyle } from '../store/usePageEditorStore';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
styles?: SectionStyles;
elementStyles?: Record<string, ElementStyle>;
props: Record<string, SectionProp>;
}
interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
url?: string;
isSpaLanding?: boolean;
}
interface InspectorPanelProps {
page: PageItem | null;
selectedSection: Section | null;
isCollapsed: boolean;
isTemplate: boolean;
availableSources: { value: string; label: string }[];
onToggleCollapse: () => void;
onSectionPropChange: (propName: string, value: SectionProp) => void;
onLayoutChange: (layout: string) => void;
onColorSchemeChange: (scheme: string) => void;
onSectionStylesChange: (styles: Partial<SectionStyles>) => void;
onElementStylesChange: (fieldName: string, styles: Partial<ElementStyle>) => void;
onDeleteSection: () => void;
onSetAsSpaLanding?: () => void;
onUnsetSpaLanding?: () => void;
onDeletePage?: () => void;
}
// Section field configurations
const SECTION_FIELDS: Record<string, { name: string; label: string; type: 'text' | 'textarea' | 'url' | 'image' | 'rte'; dynamic?: boolean }[]> = {
hero: [
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
content: [
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
'image-text': [
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
'feature-grid': [
{ name: 'heading', label: 'Heading', type: 'text' },
],
'cta-banner': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'text', label: 'Description', type: 'text' },
{ name: 'button_text', label: 'Button Text', type: 'text' },
{ name: 'button_url', label: 'Button URL', type: 'url' },
],
'contact-form': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
],
};
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
hero: [
{ value: 'default', label: 'Centered' },
{ value: 'hero-left-image', label: 'Image Left' },
{ value: 'hero-right-image', label: 'Image Right' },
],
'image-text': [
{ value: 'image-left', label: 'Image Left' },
{ value: 'image-right', label: 'Image Right' },
],
'feature-grid': [
{ value: 'grid-2', label: '2 Columns' },
{ value: 'grid-3', label: '3 Columns' },
{ value: 'grid-4', label: '4 Columns' },
],
content: [
{ value: 'default', label: 'Full Width' },
{ value: 'narrow', label: 'Narrow' },
{ value: 'medium', label: 'Medium' },
],
};
const COLOR_SCHEMES = [
{ value: 'default', label: 'Default' },
{ value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'muted', label: 'Muted' },
{ value: 'gradient', label: 'Gradient' },
];
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
hero: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'cta_text', label: 'Button', type: 'text' },
],
content: [
{ name: 'heading', label: 'Headings', type: 'text' },
{ name: 'text', label: 'Body Text', type: 'text' },
{ name: 'link', label: 'Links', type: 'text' },
{ name: 'image', label: 'Images', type: 'image' },
{ name: 'button', label: 'Button', type: 'text' },
{ name: 'content', label: 'Container', type: 'text' }, // Keep for backward compat or wrapper style
],
'image-text': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'text', label: 'Text', type: 'text' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'button', label: 'Button', type: 'text' },
],
'feature-grid': [
{ name: 'heading', label: 'Heading', type: 'text' },
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
],
'cta-banner': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'text', label: 'Description', type: 'text' },
{ name: 'button_text', label: 'Button', type: 'text' },
],
'contact-form': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'button', label: 'Button', type: 'text' },
{ name: 'fields', label: 'Input Fields', type: 'text' },
],
};
export function InspectorPanel({
page,
selectedSection,
isCollapsed,
isTemplate,
availableSources,
onToggleCollapse,
onSectionPropChange,
onLayoutChange,
onColorSchemeChange,
onSectionStylesChange,
onElementStylesChange,
onDeleteSection,
onSetAsSpaLanding,
onUnsetSpaLanding,
onDeletePage,
}: InspectorPanelProps) {
if (isCollapsed) {
return (
<div className="w-10 border-l bg-white flex flex-col items-center py-4">
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="mb-4">
<PanelRight className="w-4 h-4" />
</Button>
</div>
);
}
if (!selectedSection) {
return (
<div className="w-80 border-l bg-white flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b shrink-0">
<h3 className="font-semibold text-sm">{__('Page Settings')}</h3>
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8">
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
<div className="p-4 space-y-4 overflow-y-auto">
<div className="flex items-center gap-2 text-sm font-medium text-gray-700">
<Settings className="w-4 h-4" />
{isTemplate ? __('Template Info') : __('Page Info')}
</div>
<div className="space-y-4 bg-gray-50 p-3 rounded-lg border">
<div>
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Type')}</Label>
<p className="text-sm font-medium">
{isTemplate ? __('Template (Dynamic)') : __('Page (Structural)')}
</p>
</div>
{page?.title && (
<div>
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Title')}</Label>
<p className="text-sm font-medium">{page.title}</p>
</div>
)}
{page?.url && (
<div>
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('URL')}</Label>
<a href={page.url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1">
{__('View Page')}
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
{/* SPA Landing Settings - Only for Pages */}
{!isTemplate && page && (
<div className="pt-2 border-t mt-2">
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('SPA Landing Page')}</Label>
{page.isSpaLanding ? (
<div className="space-y-2">
<div className="bg-green-50 text-green-700 px-3 py-2 rounded-md text-sm flex items-center gap-2 border border-green-100">
<Home className="w-4 h-4" />
<span className="font-medium">{__('This is your SPA Landing')}</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
onClick={onUnsetSpaLanding}
>
<Trash2 className="w-4 h-4 mr-2" />
{__('Unset Landing Page')}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={onSetAsSpaLanding}
>
<Home className="w-4 h-4 mr-2" />
{__('Set as SPA Landing')}
</Button>
)}
</div>
)}
{/* Danger Zone */}
{!isTemplate && page && onDeletePage && (
<div className="pt-2 border-t mt-2">
<Label className="text-xs text-red-600 uppercase tracking-wider block mb-2">{__('Danger Zone')}</Label>
<Button
variant="outline"
size="sm"
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
onClick={onDeletePage}
>
<Trash2 className="w-4 h-4 mr-2" />
{__('Delete This Page')}
</Button>
</div>
)}
</div>
<div className="bg-blue-50 text-blue-800 p-3 rounded text-xs leading-relaxed">
{__('Select any section on the canvas to edit its content and design.')}
</div>
</div>
</div >
);
}
return (
<div
className={cn(
"w-80 border-l bg-white flex flex-col transition-all duration-300 shadow-xl z-30",
isCollapsed && "w-0 overflow-hidden border-none"
)}
>
{/* Header */}
<div className="h-14 border-b flex items-center justify-between px-4 shrink-0 bg-white">
<span className="font-semibold text-sm truncate">
{SECTION_FIELDS[selectedSection.type]
? (selectedSection.type.charAt(0).toUpperCase() + selectedSection.type.slice(1)).replace('-', ' ')
: 'Settings'}
</span>
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8 hover:bg-gray-100">
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
{/* Content Tabs (Content vs Design) */}
<Tabs defaultValue="content" className="flex-1 flex flex-col overflow-hidden">
<div className="px-4 pt-4 shrink-0 bg-white">
<TabsList className="w-full grid grid-cols-2">
<TabsTrigger value="content">{__('Content')}</TabsTrigger>
<TabsTrigger value="design">{__('Design')}</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-y-auto">
{/* Content Tab */}
<TabsContent value="content" className="p-4 space-y-6 m-0">
{/* Structure & Presets */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Structure')}</h4>
{LAYOUT_OPTIONS[selectedSection.type] && (
<div className="space-y-2">
<Label className="text-xs">{__('Layout Variant')}</Label>
<Select
value={selectedSection.layoutVariant || 'default'}
onValueChange={onLayoutChange}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{LAYOUT_OPTIONS[selectedSection.type].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label className="text-xs">{__('Preset Scheme')}</Label>
<Select
value={selectedSection.colorScheme || 'default'}
onValueChange={onColorSchemeChange}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{COLOR_SCHEMES.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="w-full h-px bg-gray-100" />
{/* Fields */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Fields')}</h4>
{SECTION_FIELDS[selectedSection.type]?.map((field) => (
<React.Fragment key={field.name}>
<InspectorField
fieldName={field.name}
fieldLabel={field.label}
fieldType={field.type}
value={selectedSection.props[field.name] || { type: 'static', value: '' }}
onChange={(val) => onSectionPropChange(field.name, val)}
supportsDynamic={field.dynamic && isTemplate}
availableSources={availableSources}
/>
{selectedSection.type === 'contact-form' && field.name === 'redirect_url' && (
<p className="text-[10px] text-gray-500 mt-1 pl-1">
Available shortcodes: {'{name}'}, {'{email}'}, {'{date}'}
</p>
)}
{selectedSection.type === 'contact-form' && field.name === 'webhook_url' && (
<Accordion type="single" collapsible className="w-full mt-1 border rounded-md">
<AccordionItem value="payload" className="border-0">
<AccordionTrigger className="text-[10px] py-1 px-2 hover:no-underline text-gray-500">
View Payload Example
</AccordionTrigger>
<AccordionContent className="px-2 pb-2">
<pre className="text-[10px] bg-gray-50 p-2 rounded overflow-x-auto">
{JSON.stringify({
"form_id": "contact_form_123",
"fields": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello world"
},
"meta": {
"url": "https://site.com/contact",
"timestamp": 1710000000
}
}, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</React.Fragment>
))}
</div>
{/* Feature Grid Repeater */}
{selectedSection.type === 'feature-grid' && (
<div className="pt-4 border-t">
<InspectorRepeater
label={__('Features')}
items={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []}
onChange={(items) => onSectionPropChange('features', { type: 'static', value: items })}
fields={[
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'description', label: 'Description', type: 'textarea' },
{ name: 'icon', label: 'Icon', type: 'icon' },
]}
itemLabelKey="title"
/>
</div>
)}
</TabsContent>
{/* Design Tab */}
<TabsContent value="design" className="p-4 space-y-6 m-0">
{/* Background */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4>
<div className="space-y-2">
<Label className="text-xs">{__('Background Color')}</Label>
<div className="flex gap-2">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.backgroundColor || '#ffffff'}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="#FFFFFF"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
value={selectedSection.styles?.backgroundColor || ''}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">{__('Background Image')}</Label>
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
{selectedSection.styles?.backgroundImage ? (
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-medium">{__('Change')}</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
) : (
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
<Palette className="w-6 h-6" />
{__('Select Image')}
</Button>
)}
</MediaUploader>
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{__('Overlay Opacity')}</Label>
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
</div>
<Slider
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
max={100}
step={5}
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
/>
</div>
<div className="space-y-2 pt-2">
<Label className="text-xs">{__('Section Height')}</Label>
<Select
value={selectedSection.styles?.heightPreset || 'default'}
onValueChange={(val) => {
// Map presets to padding values
const paddingMap: Record<string, string> = {
'default': '0',
'small': '0',
'medium': '0',
'large': '0',
'screen': '0',
};
const padding = paddingMap[val] || '4rem';
// If screen, we might need a specific flag, but for now lets reuse paddingTop/Bottom or add a new prop.
// To avoid breaking schema, let's use paddingTop as the "preset carrier" or add a new styles prop if possible.
// Since styles key is SectionStyles, let's stick to modifying paddingTop/Bottom for now as a simple preset.
onSectionStylesChange({
paddingTop: padding,
paddingBottom: padding,
heightPreset: val // We'll add this to interface
} as any);
}}
>
<SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="small">Small (Compact)</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="large">Large (Spacious)</SelectItem>
<SelectItem value="screen">Full Screen</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="w-full h-px bg-gray-100" />
{/* Element Styles */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{__('Element Styles')}</h4>
<Accordion type="single" collapsible className="w-full">
{(STYLABLE_ELEMENTS[selectedSection.type] || []).map((field) => {
const styles = selectedSection.elementStyles?.[field.name] || {};
const isImage = field.type === 'image';
return (
<AccordionItem key={field.name} value={field.name}>
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-2">
{/* Common: Background Wrapper */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.backgroundColor || '#ffffff'}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Color (#fff)"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.backgroundColor || ''}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
</div>
{!isImage ? (
<>
{/* Text Color */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Text Color')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.color || '#000000' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.color || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Color (#000)"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.color || ''}
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
</div>
</div>
{/* Typography Group */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Typography')}</Label>
<Select value={styles.fontFamily || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontFamily: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Font Family" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="primary">Primary (Headings)</SelectItem>
<SelectItem value="secondary">Secondary (Body)</SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-2">
<Select value={styles.fontSize || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontSize: val === 'default' ? undefined : val })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Size" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Size</SelectItem>
<SelectItem value="text-sm">Small</SelectItem>
<SelectItem value="text-base">Base</SelectItem>
<SelectItem value="text-lg">Large</SelectItem>
<SelectItem value="text-xl">XL</SelectItem>
<SelectItem value="text-2xl">2XL</SelectItem>
<SelectItem value="text-3xl">3XL</SelectItem>
<SelectItem value="text-4xl">4XL</SelectItem>
<SelectItem value="text-5xl">5XL</SelectItem>
<SelectItem value="text-6xl">6XL</SelectItem>
</SelectContent>
</Select>
<Select value={styles.fontWeight || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontWeight: val === 'default' ? undefined : val })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Weight" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Weight</SelectItem>
<SelectItem value="font-light">Light</SelectItem>
<SelectItem value="font-normal">Normal</SelectItem>
<SelectItem value="font-medium">Medium</SelectItem>
<SelectItem value="font-semibold">Semibold</SelectItem>
<SelectItem value="font-bold">Bold</SelectItem>
<SelectItem value="font-extrabold">Extra Bold</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Align</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
{/* Link Specific Styles */}
{field.name === 'link' && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Link Styles')}</Label>
<Select value={styles.textDecoration || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textDecoration: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Decoration" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="none">None</SelectItem>
<SelectItem value="underline">Underline</SelectItem>
</SelectContent>
</Select>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Hover Color')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.hoverColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.hoverColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Hover Color"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.hoverColor || ''}
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
</div>
</div>
</div>
)}
{/* Button/Box Specific Styles */}
{field.name === 'button' && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
<div className="flex items-center gap-2 h-7">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.borderColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
<input type="text" placeholder="e.g. 1px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
<input type="text" placeholder="e.g. 4px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
<input type="text" placeholder="e.g. 8px 16px" className="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
</div>
</div>
</div>
)}
</>
) : (
<>
{/* Image Settings */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="cover">Cover</SelectItem>
<SelectItem value="contain">Contain</SelectItem>
<SelectItem value="fill">Fill</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Width')}</Label>
<input type="text" placeholder="e.g. 100%" className="w-full h-7 text-xs rounded border px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Height')}</Label>
<input type="text" placeholder="e.g. auto" className="w-full h-7 text-xs rounded border px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
</div>
</div>
</>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</TabsContent>
</div>
{/* Footer - Delete Button */}
{
selectedSection && (
<div className="p-4 border-t mt-auto shrink-0 bg-gray-50/50">
<Button variant="destructive" className="w-full" onClick={onDeleteSection}>
<Trash2 className="w-4 h-4 mr-2" />
{__('Delete Section')}
</Button>
</div>
)
}
</Tabs >
</div >
);
}

View File

@@ -0,0 +1,233 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
interface RepeaterFieldDef {
name: string;
label: string;
type: 'text' | 'textarea' | 'url' | 'image' | 'icon';
placeholder?: string;
}
interface InspectorRepeaterProps {
label: string;
items: any[];
fields: RepeaterFieldDef[];
onChange: (items: any[]) => void;
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title')
}
// Sortable Item Component
function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelete }: any) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// List of available icons for selection
const ICON_OPTIONS = [
'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings',
'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase',
'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database',
'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image',
'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle',
'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power',
'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart',
'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool',
'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2',
'Wifi', 'Wrench'
].sort();
return (
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
<AccordionItem value={`item-${index}`} className="border-0">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
<button {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
<GripVertical className="w-4 h-4" />
</button>
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
{item[itemLabelKey] || `Item ${index + 1}`}
</AccordionTrigger>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={(e) => {
e.stopPropagation();
onDelete(index);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<AccordionContent className="p-3 space-y-3">
{fields.map((field: RepeaterFieldDef) => (
<div key={field.name} className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
{field.type === 'textarea' ? (
<Textarea
value={item[field.name] || ''}
onChange={(e) => onChange(index, field.name, e.target.value)}
placeholder={field.placeholder}
className="text-xs min-h-[60px]"
/>
) : field.type === 'icon' ? (
<Select
value={item[field.name] || ''}
onValueChange={(val) => onChange(index, field.name, val)}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select an icon" />
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{ICON_OPTIONS.map(iconName => (
<SelectItem key={iconName} value={iconName}>
<div className="flex items-center gap-2">
<span>{iconName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type="text"
value={item[field.name] || ''}
onChange={(e) => onChange(index, field.name, e.target.value)}
placeholder={field.placeholder}
className="h-8 text-xs"
/>
)}
</div>
))}
</AccordionContent>
</AccordionItem>
</div>
);
}
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title' }: InspectorRepeaterProps) {
// Generate simple stable IDs for sorting if items don't have them
const itemIds = items.map((_, i) => `item-${i}`);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = itemIds.indexOf(active.id as string);
const newIndex = itemIds.indexOf(over.id as string);
onChange(arrayMove(items, oldIndex, newIndex));
}
};
const handleItemChange = (index: number, fieldName: string, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [fieldName]: value };
onChange(newItems);
};
const handleAddItem = () => {
const newItem: any = {};
fields.forEach(f => newItem[f.name] = '');
onChange([...items, newItem]);
};
const handleDeleteItem = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
onChange(newItems);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
<Plus className="w-3 h-3 mr-1" />
Add Item
</Button>
</div>
<Accordion type="single" collapsible className="w-full">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={itemIds}
strategy={verticalListSortingStrategy}
>
{items.map((item, index) => (
<SortableItem
key={`item-${index}`} // Note: In a real app with IDs, use item.id
id={`item-${index}`}
index={index}
item={item}
fields={fields}
itemLabelKey={itemLabelKey}
onChange={handleItemChange}
onDelete={handleDeleteItem}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
{items.length === 0 && (
<div className="text-xs text-gray-400 text-center py-4 border border-dashed rounded-md bg-gray-50">
No items yet. Click "Add Item" to start.
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { __ } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { FileText, Layout, Loader2 } from 'lucide-react';
import { FileText, Layout, Loader2, Home } from 'lucide-react';
interface PageItem {
id?: number;
@@ -51,14 +51,19 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
key={`page-${page.id}`}
onClick={() => onSelectPage(page)}
className={cn(
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between group',
'hover:bg-gray-100',
selectedPage?.id === page.id && selectedPage?.type === 'page'
? 'bg-primary/10 text-primary font-medium'
: 'text-gray-700'
)}
>
{page.title}
<span className="truncate">{page.title}</span>
{(page as any).isSpaLanding && (
<span title="SPA Landing Page" className="flex-shrink-0 ml-2">
<Home className="w-3.5 h-3.5 text-green-600" />
</span>
)}
</button>
))
)}

View File

@@ -31,6 +31,17 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
interface Section {
id: string;
@@ -158,18 +169,36 @@ function SortableSectionCard({
>
<ChevronDown className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => {
if (confirm(__('Delete this section?'))) {
onDelete();
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent className="z-[60]">
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
<AlertDialogDescription>
{__('This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</Card>

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { ArrowRight } from 'lucide-react';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>;
}
interface CTABannerRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string; btnBg: string; btnText: string }> = {
default: { bg: '', text: 'text-gray-900', btnBg: 'bg-blue-600', btnText: 'text-white' },
primary: { bg: 'bg-blue-600', text: 'text-white', btnBg: 'bg-white', btnText: 'text-blue-600' },
secondary: { bg: 'bg-gray-800', text: 'text-white', btnBg: 'bg-white', btnText: 'text-gray-800' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700', btnBg: 'bg-gray-800', btnText: 'text-white' },
gradient: { bg: 'bg-gradient-to-r from-purple-600 to-blue-500', text: 'text-white', btnBg: 'bg-white', btnText: 'text-purple-600' },
};
export function CTABannerRenderer({ section, className }: CTABannerRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'];
const title = section.props?.title?.value || 'Ready to get started?';
const text = section.props?.text?.value || 'Join thousands of happy customers today.';
const buttonText = section.props?.button_text?.value || 'Get Started';
const buttonUrl = section.props?.button_url?.value || '#';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const btnStyle = getTextStyles('button_text');
return (
<div className={cn('py-12 px-4 md:py-20 md:px-8', scheme.bg, scheme.text, className)}>
<div className="max-w-4xl mx-auto text-center space-y-6">
<h2
className={cn(
!titleStyle.classNames && "text-3xl md:text-4xl font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
<p
className={cn(
"max-w-2xl mx-auto",
!textStyle.classNames && "text-lg opacity-90",
textStyle.classNames
)}
style={textStyle.style}
>
{text}
</p>
<button className={cn(
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',
!btnStyle.style?.backgroundColor && scheme.btnBg,
!btnStyle.style?.color && scheme.btnText,
btnStyle.classNames
)}
style={btnStyle.style}
>
{buttonText}
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Send, Mail, User, MessageSquare } from 'lucide-react';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>;
}
interface ContactFormRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string; inputBg: string; btnBg: string }> = {
default: { bg: '', text: 'text-gray-900', inputBg: 'bg-gray-50', btnBg: 'bg-blue-600' },
primary: { bg: 'wn-primary-bg', text: 'text-white', inputBg: 'bg-white', btnBg: 'bg-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white', inputBg: 'bg-gray-700', btnBg: 'bg-blue-500' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700', inputBg: 'bg-white', btnBg: 'bg-gray-800' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white', inputBg: 'bg-white/90', btnBg: 'bg-white' },
};
export function ContactFormRenderer({ section, className }: ContactFormRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const title = section.props?.title?.value || 'Contact Us';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign
}
};
};
const titleStyle = getTextStyles('title');
const buttonStyleObj = getTextStyles('button');
const fieldsStyleObj = getTextStyles('fields');
const buttonStyle = section.elementStyles?.button || {};
const fieldsStyle = section.elementStyles?.fields || {};
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className="max-w-xl mx-auto">
<h2
className={cn(
"text-3xl font-bold text-center mb-8",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
{/* Name field */}
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Your Name"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Email field */}
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
placeholder="Your Email"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Message field */}
<div className="relative">
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
<textarea
placeholder="Your Message"
rows={4}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Submit button */}
<button
type="submit"
className={cn(
'w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition opacity-80 cursor-not-allowed',
!buttonStyle.backgroundColor && scheme.btnBg,
!buttonStyle.color && (section.colorScheme === 'primary' || section.colorScheme === 'gradient' ? 'text-blue-600' : 'text-white'),
buttonStyleObj.classNames
)}
style={{
backgroundColor: buttonStyle.backgroundColor,
color: buttonStyle.color
}}
disabled
>
<Send className="w-5 h-5" />
Send Message
</button>
<p className="text-center text-sm opacity-60">
(Form preview only - functional on frontend)
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Section } from '../../store/usePageEditorStore';
interface ContentRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: 'bg-white', text: 'text-gray-900' },
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
dark: { bg: 'bg-gray-900', text: 'text-white' },
blue: { bg: 'wn-primary-bg', text: 'text-white' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
const WIDTH_CLASSES: Record<string, string> = {
default: 'max-w-6xl',
narrow: 'max-w-2xl',
medium: 'max-w-4xl',
};
const fontSizeToCSS = (className?: string) => {
switch (className) {
case 'text-sm': return '0.875rem';
case 'text-base': return '1rem';
case 'text-lg': return '1.125rem';
case 'text-xl': return '1.25rem';
case 'text-2xl': return '1.5rem';
case 'text-3xl': return '1.875rem';
case 'text-4xl': return '2.25rem';
case 'text-5xl': return '3rem';
case 'text-6xl': return '3.75rem';
default: return undefined;
}
};
const fontWeightToCSS = (className?: string) => {
switch (className) {
case 'font-light': return '300';
case 'font-normal': return '400';
case 'font-medium': return '500';
case 'font-semibold': return '600';
case 'font-bold': return '700';
case 'font-extrabold': return '800';
default: return undefined;
}
};
// Helper to generate scoped CSS for prose elements
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
const styles: string[] = [];
const scope = `#section-${sectionId}`;
// Headings (h1-h4)
const hs = elementStyles?.heading;
if (hs) {
const headingRules = [
hs.color && `color: ${hs.color} !important;`,
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
// Add padding if background color is set to make it look decent
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
].filter(Boolean).join(' ');
if (headingRules) {
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
}
}
// Body text (p, li)
const ts = elementStyles?.text;
if (ts) {
const textRules = [
ts.color && `color: ${ts.color} !important;`,
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
].filter(Boolean).join(' ');
if (textRules) {
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
}
}
// Explicit Spacing & List Formatting Restorations
// These ensure vertical rhythm and list styles exist even if prose defaults are overridden or missing
styles.push(`
${scope} p { margin-bottom: 1em; }
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
${scope} li { margin-bottom: 0.25em; }
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
`);
// Links (a:not(.button))
const ls = elementStyles?.link;
if (ls) {
const linkRules = [
ls.color && `color: ${ls.color} !important;`,
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
].filter(Boolean).join(' ');
if (linkRules) {
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
}
if (ls.hoverColor) {
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
}
}
// Buttons (a[data-button], .button)
const bs = elementStyles?.button;
if (bs) {
const btnRules = [
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
bs.color && `color: ${bs.color} !important;`,
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
bs.padding && `padding: ${bs.padding} !important;`,
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
].filter(Boolean).join(' ');
// Always force text-decoration: none for buttons
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
// Add hover effect opacity or something to make it feel alive, or just keep it simple
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
}
// Images
const is = elementStyles?.image;
if (is) {
const imgRules = [
is.objectFit && `object-fit: ${is.objectFit} !important;`,
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
is.width && `width: ${is.width} !important;`,
is.height && `height: ${is.height} !important;`,
].filter(Boolean).join(' ');
if (imgRules) {
styles.push(`${scope} img { ${imgRules} }`);
}
}
return styles.join('\n');
};
export function ContentRenderer({ section, className }: ContentRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'default';
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
const heightPreset = section.styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
const isDynamic = section.props?.content?.type === 'dynamic';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const contentStyle = getTextStyles('content');
const headingStyle = getTextStyles('heading');
const buttonStyle = getTextStyles('button');
const cta_text = section.props?.cta_text?.value;
const cta_url = section.props?.cta_url?.value;
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
id={`section-${section.id}`}
className={cn(
'px-4 md:px-8',
heightClasses,
!scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text,
className
)}
style={getBackgroundStyle()}
>
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
<div
className={cn(
'mx-auto prose prose-lg max-w-none',
// Default prose overrides
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-a:no-underline hover:prose-a:underline', // Establish baseline for links
widthClass,
scheme.text === 'text-white' && 'prose-invert',
contentStyle.classNames // Apply font family, size, weight to container just in case
)}
style={{
color: contentStyle.style.color,
textAlign: contentStyle.style.textAlign as React.CSSProperties['textAlign'],
'--tw-prose-headings': headingStyle.style?.color,
} as React.CSSProperties}
>
{isDynamic && (
<div className="flex items-center gap-2 text-orange-400 text-sm font-medium mb-4">
<span></span>
<span>{section.props?.content?.source || 'Dynamic Content'}</span>
</div>
)}
<div dangerouslySetInnerHTML={{ __html: content }} />
{cta_text && cta_url && (
<div className="mt-8">
<a
href={cta_url}
className={cn(
"button inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
!buttonStyle.style?.backgroundColor && "bg-blue-600",
!buttonStyle.style?.color && "text-white",
buttonStyle.classNames
)}
style={buttonStyle.style}
>
{cta_text}
</a>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
elementStyles?: Record<string, any>;
}
interface FeatureGridRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string; cardBg: string }> = {
default: { bg: '', text: 'text-gray-900', cardBg: 'bg-gray-50' },
primary: { bg: 'wn-primary-bg', text: 'text-white', cardBg: 'bg-white/10' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white', cardBg: 'bg-white/10' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700', cardBg: 'bg-white' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white', cardBg: 'bg-white/10' },
};
const GRID_CLASSES: Record<string, string> = {
'grid-2': 'grid-cols-1 md:grid-cols-2',
'grid-3': 'grid-cols-1 md:grid-cols-3',
'grid-4': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
// Default features for demo
const DEFAULT_FEATURES = [
{ title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' },
{ title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' },
{ title: 'Quality Products', description: 'Only the best for our customers', icon: 'Star' },
];
export function FeatureGridRenderer({ section, className }: FeatureGridRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'grid-3';
const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3'];
const heading = section.props?.heading?.value || 'Our Features';
const features = section.props?.features?.value || DEFAULT_FEATURES;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item');
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className="max-w-6xl mx-auto">
{heading && (
<h2
className={cn(
"text-3xl md:text-4xl font-bold text-center mb-12",
headingStyle.classNames
)}
style={headingStyle.style}
>
{heading}
</h2>
)}
<div className={cn('grid gap-8', gridClass)}>
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
// Resolve icon from name, fallback to Star
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
return (
<div
key={index}
className={cn(
'p-6 rounded-xl text-center',
!featureItemStyle.style?.backgroundColor && scheme.cardBg,
featureItemStyle.classNames
)}
style={featureItemStyle.style}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
<IconComponent className="w-7 h-7 text-blue-600" />
</div>
<h3
className={cn(
"mb-2",
!featureItemStyle.style?.color && "text-lg font-semibold"
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.title || `Feature ${index + 1}`}
</h3>
<p
className={cn(
"text-sm",
!featureItemStyle.style?.color && "opacity-80"
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.description || 'Feature description goes here'}
</p>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>;
}
interface HeroRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: '', text: 'text-gray-900' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
export function HeroRenderer({ section, className }: HeroRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'default';
const title = section.props?.title?.value || 'Hero Title';
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
const image = section.props?.image?.value;
const ctaText = section.props?.cta_text?.value || 'Get Started';
const ctaUrl = section.props?.cta_url?.value || '#';
// Check for dynamic placeholders
const isDynamicTitle = section.props?.title?.type === 'dynamic';
const isDynamicSubtitle = section.props?.subtitle?.type === 'dynamic';
const isDynamicImage = section.props?.image?.type === 'dynamic';
// Element Styles
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary', // Mapping secondary to sans for now if primary is assumed default
'font-serif': styles.fontFamily === 'primary', // Mapping primary to serif (headings) - ADJUST AS NEEDED
}
),
style: {
color: styles.color,
backgroundColor: styles.backgroundColor,
textAlign: styles.textAlign
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const ctaStyle = getTextStyles('cta_text'); // For button
// Helper for image styles
const imageStyle = section.elementStyles?.['image'] || {};
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className={cn(
'max-w-6xl mx-auto flex items-center gap-12',
layout === 'hero-right-image' ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
'flex-wrap md:flex-nowrap'
)}>
{/* Image */}
<div className="w-full md:w-1/2">
<div
className="rounded-lg shadow-xl overflow-hidden"
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%'
}}
>
{image ? (
<img
src={image}
alt={title}
className="w-full h-auto block"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
}}
/>
) : (
<div className="w-full h-64 md:h-80 bg-gray-300 flex items-center justify-center">
<span className="text-gray-500">
{isDynamicImage ? `${section.props?.image?.source}` : 'No Image'}
</span>
</div>
)}
</div>
</div>
{/* Content */}
<div className="w-full md:w-1/2 space-y-4">
<h1
className={cn("font-bold", titleStyle.classNames || "text-3xl md:text-5xl")}
style={titleStyle.style}
>
{isDynamicTitle && <span className="text-orange-400 mr-2"></span>}
{title}
</h1>
<p
className={cn("opacity-90", subtitleStyle.classNames || "text-lg md:text-xl")}
style={subtitleStyle.style}
>
{isDynamicSubtitle && <span className="text-orange-400 mr-2"></span>}
{subtitle}
</p>
{ctaText && (
<button
className={cn(
"px-6 py-3 rounded-lg transition hover:opacity-90",
!ctaStyle.style?.backgroundColor && "bg-white",
!ctaStyle.style?.color && "text-gray-900",
ctaStyle.classNames
)}
style={ctaStyle.style}
>
{ctaText}
</button>
)}
</div>
</div>
</div>
);
}
// Default centered layout
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8 text-center', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className="max-w-4xl mx-auto space-y-6">
<h1
className={cn("font-bold", titleStyle.classNames || "text-4xl md:text-6xl")}
style={titleStyle.style}
>
{isDynamicTitle && <span className="text-orange-400 mr-2"></span>}
{title}
</h1>
<p
className={cn("opacity-90 max-w-2xl mx-auto", subtitleStyle.classNames || "text-lg md:text-2xl")}
style={subtitleStyle.style}
>
{isDynamicSubtitle && <span className="text-orange-400 mr-2"></span>}
{subtitle}
</p>
{/* Image with Wrapper for Background */}
<div
className={cn("mx-auto", imageStyle.width ? "" : "max-w-3xl w-full")}
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
>
{image ? (
<img
src={image}
alt={title}
className={cn(
"w-full rounded-xl shadow-lg mt-8",
!imageStyle.height && "h-auto", // Default height if not specified
!imageStyle.objectFit && "object-cover" // Default fit if not specified
)}
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
maxWidth: '100%',
}}
/>
) : isDynamicImage ? (
<div className="w-full h-64 bg-gray-300 rounded-xl flex items-center justify-center mt-8">
<span className="text-gray-500"> {section.props?.image?.source}</span>
</div>
) : null}
</div>
{ctaText && (
<button
className={cn(
"px-8 py-4 rounded-lg transition hover:opacity-90 mt-4",
!ctaStyle.style?.backgroundColor && "bg-white",
!ctaStyle.style?.color && "text-gray-900",
ctaStyle.classNames || "font-semibold"
)}
style={ctaStyle.style}
>
{ctaText}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Section } from '../../store/usePageEditorStore';
interface ImageTextRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: '', text: 'text-gray-900' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
export function ImageTextRenderer({ section, className }: ImageTextRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'image-left';
const isImageRight = layout === 'image-right';
const title = section.props?.title?.value || 'Section Title';
const text = section.props?.text?.value || 'Your descriptive text goes here. Edit this section to add your own content.';
const image = section.props?.image?.value;
const isDynamicTitle = section.props?.title?.type === 'dynamic';
const isDynamicText = section.props?.text?.type === 'dynamic';
const isDynamicImage = section.props?.image?.type === 'dynamic';
const cta_text = section.props?.cta_text?.value;
const cta_url = section.props?.cta_url?.value;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const imageStyle = section.elementStyles?.['image'] || {};
const buttonStyle = getTextStyles('button');
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className={cn(
'max-w-6xl mx-auto flex items-center gap-12',
isImageRight ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
'flex-wrap md:flex-nowrap'
)}>
{/* Image */}
<div className="w-full md:w-1/2" style={{ backgroundColor: imageStyle.backgroundColor }}>
{image ? (
<img
src={image}
alt={title}
className="w-full h-auto rounded-xl shadow-lg"
style={{
objectFit: imageStyle.objectFit,
width: imageStyle.width,
maxWidth: '100%',
height: imageStyle.height,
}}
/>
) : (
<div className="w-full h-64 md:h-80 bg-gray-200 rounded-xl flex items-center justify-center">
<span className="text-gray-400">
{isDynamicImage ? (
<span className="flex items-center gap-2">
<span className="text-orange-400"></span>
{section.props?.image?.source}
</span>
) : (
'Add Image'
)}
</span>
</div>
)}
</div>
{/* Content */}
<div className="w-full md:w-1/2 space-y-4">
<h2
className={cn(
"text-2xl md:text-3xl font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{isDynamicTitle && <span className="text-orange-400 mr-2"></span>}
{title}
</h2>
<p
className={cn(
"text-lg opacity-90 leading-relaxed",
textStyle.classNames
)}
style={textStyle.style}
>
{isDynamicText && <span className="text-orange-400 mr-2"></span>}
{text}
</p>
{cta_text && cta_url && (
<div className="pt-4">
<span
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
!buttonStyle.style?.backgroundColor && "bg-blue-600",
!buttonStyle.style?.color && "text-white",
buttonStyle.classNames
)}
style={buttonStyle.style}
>
{cta_text}
</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { HeroRenderer } from './HeroRenderer';
export { ContentRenderer } from './ContentRenderer';
export { ImageTextRenderer } from './ImageTextRenderer';
export { FeatureGridRenderer } from './FeatureGridRenderer';
export { CTABannerRenderer } from './CTABannerRenderer';
export { ContactFormRenderer } from './ContactFormRenderer';

View File

@@ -1,16 +1,16 @@
import React, { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus, FileText, Layout, Trash2, Eye, Settings } from 'lucide-react';
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { PageSidebar } from './components/PageSidebar';
import { SectionEditor } from './components/SectionEditor';
import { PageSettings } from './components/PageSettings';
import { CanvasRenderer } from './components/CanvasRenderer';
import { InspectorPanel } from './components/InspectorPanel';
import { CreatePageModal } from './components/CreatePageModal';
import { usePageEditorStore, Section } from './store/usePageEditorStore';
// Types
interface PageItem {
@@ -23,75 +23,109 @@ interface PageItem {
icon?: string;
has_template?: boolean;
permalink_base?: string;
}
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
}
interface PageStructure {
type: 'page' | 'template';
sections: Section[];
updated_at?: string;
isFrontPage?: boolean;
}
export default function AppearancePages() {
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<PageItem | null>(null);
const [selectedSection, setSelectedSection] = useState<Section | null>(null);
const [structure, setStructure] = useState<PageStructure | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Zustand store
const {
currentPage,
sections,
selectedSectionId,
deviceMode,
inspectorCollapsed,
hasUnsavedChanges,
isLoading,
availableSources,
setCurrentPage,
setSections,
setSelectedSection,
setDeviceMode,
setInspectorCollapsed,
setAvailableSources,
setIsLoading,
addSection,
deleteSection,
duplicateSection,
moveSection,
reorderSections,
updateSectionProp,
updateSectionLayout,
updateSectionColorScheme,
updateSectionStyles,
updateElementStyles,
markAsSaved,
setAsSpaLanding,
unsetSpaLanding,
} = usePageEditorStore();
// Get selected section object
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
// Fetch all pages and templates
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
queryKey: ['pages'],
queryFn: async () => {
// api.get returns JSON directly (not wrapped in { data: ... })
const response = await api.get('/pages');
return response; // Return response directly
// Map API snake_case to frontend camelCase
return response.map((p: any) => ({
...p,
isSpaLanding: !!p.is_spa_frontpage
}));
},
});
// Fetch selected page/template structure
const { data: pageData, isLoading: pageLoading } = useQuery({
queryKey: ['page-structure', selectedPage?.type, selectedPage?.slug || selectedPage?.cpt],
queryKey: ['page-structure', currentPage?.type, currentPage?.slug || currentPage?.cpt],
queryFn: async () => {
if (!selectedPage) return null;
const endpoint = selectedPage.type === 'page'
? `/pages/${selectedPage.slug}`
: `/templates/${selectedPage.cpt}`;
// api.get returns JSON directly
if (!currentPage) return null;
const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`;
const response = await api.get(endpoint);
return response; // Return response directly
return response;
},
enabled: !!selectedPage,
enabled: !!currentPage,
});
// Update local structure when page data loads
// Update store when page data loads
useEffect(() => {
if (pageData?.structure) {
setStructure(pageData.structure);
setHasUnsavedChanges(false);
if (pageData?.structure?.sections) {
setSections(pageData.structure.sections);
markAsSaved();
}
}, [pageData]);
if (pageData?.available_sources) {
setAvailableSources(pageData.available_sources);
}
// Sync isFrontPage if returned from single page API (optional, but good practice)
if (pageData?.is_front_page !== undefined && currentPage) {
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
// Update loading state
useEffect(() => {
setIsLoading(pageLoading);
}, [pageLoading, setIsLoading]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedPage || !structure) return;
const endpoint = selectedPage.type === 'page'
? `/pages/${selectedPage.slug}`
: `/templates/${selectedPage.cpt}`;
return api.post(endpoint, { sections: structure.sections });
if (!currentPage) return;
const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`;
return api.post(endpoint, { sections });
},
onSuccess: () => {
toast.success(__('Page saved successfully'));
setHasUnsavedChanges(false);
markAsSaved();
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
},
onError: () => {
@@ -99,166 +133,244 @@ export default function AppearancePages() {
},
});
// Handle section update
const handleSectionUpdate = (updatedSection: Section) => {
if (!structure) return;
const newSections = structure.sections.map(s =>
s.id === updatedSection.id ? updatedSection : s
);
setStructure({ ...structure, sections: newSections });
setSelectedSection(updatedSection);
setHasUnsavedChanges(true);
};
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
return api.del(`/pages/${id}`);
},
onSuccess: () => {
toast.success(__('Page deleted successfully'));
markAsSaved(); // Clear unsaved flag
setCurrentPage(null);
queryClient.invalidateQueries({ queryKey: ['pages'] });
},
onError: () => {
toast.error(__('Failed to delete page'));
},
});
// Add new section
const handleAddSection = (sectionType: string) => {
if (!structure) {
setStructure({
type: selectedPage?.type || 'page',
sections: [],
// Set as SPA Landing mutation
const setSpaLandingMutation = useMutation({
mutationFn: async (id: number) => {
return api.post(`/pages/${id}/set-as-spa-landing`);
},
onSuccess: () => {
toast.success(__('Set as SPA Landing Page'));
queryClient.invalidateQueries({ queryKey: ['pages'] });
// Update local state is handled by re-fetching pages or we can optimistic update
if (currentPage) {
setCurrentPage({ ...currentPage, isSpaLanding: true });
}
},
onError: () => {
toast.error(__('Failed to set SPA Landing Page'));
},
});
// Unset SPA Landing mutation
const unsetSpaLandingMutation = useMutation({
mutationFn: async () => {
return api.post(`/pages/unset-spa-landing`);
},
onSuccess: () => {
toast.success(__('Unset SPA Landing Page'));
queryClient.invalidateQueries({ queryKey: ['pages'] });
if (currentPage) {
setCurrentPage({ ...currentPage, isSpaLanding: false });
}
},
onError: () => {
toast.error(__('Failed to unset SPA Landing Page'));
},
});
// Handle page selection
const handleSelectPage = (page: PageItem) => {
if (hasUnsavedChanges) {
if (!confirm(__('You have unsaved changes. Continue?'))) return;
}
if (page.type === 'page') {
setCurrentPage({
...page,
isSpaLanding: !!(page as any).isSpaLanding
});
}
const newSection: Section = {
id: `section-${Date.now()}`,
type: sectionType,
layoutVariant: 'default',
colorScheme: 'default',
props: {},
} else {
setCurrentPage(page);
};
setStructure(prev => ({
...prev!,
sections: [...(prev?.sections || []), newSection],
}));
setSelectedSection(newSection);
setHasUnsavedChanges(true);
setSelectedSection(null);
};
// Delete section
const handleDeleteSection = (sectionId: string) => {
if (!structure) return;
setStructure({
...structure,
sections: structure.sections.filter(s => s.id !== sectionId),
});
if (selectedSection?.id === sectionId) {
setSelectedSection(null);
const handleDiscard = () => {
if (pageData?.structure?.sections) {
setSections(pageData.structure.sections);
markAsSaved();
toast.success(__('Changes discarded'));
}
setHasUnsavedChanges(true);
};
// Move section
const handleMoveSection = (sectionId: string, direction: 'up' | 'down') => {
if (!structure) return;
const index = structure.sections.findIndex(s => s.id === sectionId);
if (index === -1) return;
const handleDeletePage = () => {
if (!currentPage || !currentPage.id) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= structure.sections.length) return;
const newSections = [...structure.sections];
[newSections[index], newSections[newIndex]] = [newSections[newIndex], newSections[index]];
setStructure({ ...structure, sections: newSections });
setHasUnsavedChanges(true);
};
// Reorder sections (drag-and-drop)
const handleReorderSections = (newSections: Section[]) => {
if (!structure) return;
setStructure({ ...structure, sections: newSections });
setHasUnsavedChanges(true);
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
deleteMutation.mutate(currentPage.id);
}
};
return (
<div className="h-[calc(100vh-64px)] flex flex-col">
<div className={
cn(
"flex flex-col bg-white transition-all duration-300",
isFullscreen ? "fixed inset-0 z-[100] h-screen" : "h-[calc(100vh-64px)]"
)
} >
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
< div className="flex items-center justify-between px-6 py-3 border-b bg-white" >
<div>
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
<p className="text-sm text-muted-foreground">
{selectedPage ? selectedPage.title : __('Select a page to edit')}
{currentPage ? currentPage.title : __('Select a page to edit')}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? __('Exit Fullscreen') : __('Enter Fullscreen')}
className="mr-2"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
{hasUnsavedChanges && (
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
<>
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
<Button
variant="ghost"
size="sm"
onClick={handleDiscard}
>
<Undo2 className="w-4 h-4 mr-2" />
{__('Discard')}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateModal(true)}
>
<Plus className="w-4 h-4 mr-2" />
{__('Create Page')}
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={!hasUnsavedChanges || saveMutation.isPending}
>
{saveMutation.isPending ? __('Saving...') : __('Save Changes')}
<Save className="w-4 h-4 mr-2" />
{saveMutation.isPending ? __('Saving...') : __('Save')}
</Button>
</div>
</div>
</div >
{/* 3-Column Layout */}
<div className="flex-1 flex overflow-hidden">
{/* 3-Column Layout: Sidebar | Canvas | Inspector */}
< div className="flex-1 flex overflow-hidden" >
{/* Left Column: Pages List */}
<PageSidebar
< PageSidebar
pages={pages}
selectedPage={selectedPage}
onSelectPage={(page) => {
if (hasUnsavedChanges) {
if (!confirm(__('You have unsaved changes. Continue?'))) return;
}
setSelectedPage(page);
setSelectedSection(null);
}}
selectedPage={currentPage}
onSelectPage={handleSelectPage}
isLoading={pagesLoading}
/>
{/* Center Column: Section Editor */}
<div className="flex-1 bg-gray-50 overflow-y-auto p-6">
{selectedPage ? (
<SectionEditor
sections={structure?.sections || []}
selectedSection={selectedSection}
{/* Center Column: Canvas Renderer */}
{
currentPage ? (
<CanvasRenderer
sections={sections}
selectedSectionId={selectedSectionId}
deviceMode={deviceMode}
onSelectSection={setSelectedSection}
onAddSection={handleAddSection}
onDeleteSection={handleDeleteSection}
onMoveSection={handleMoveSection}
onReorderSections={handleReorderSections}
isTemplate={selectedPage.type === 'template'}
cpt={selectedPage.cpt}
isLoading={pageLoading}
onAddSection={addSection}
onDeleteSection={deleteSection}
onDuplicateSection={duplicateSection}
onMoveSection={moveSection}
onReorderSections={reorderSections}
onDeviceModeChange={setDeviceMode}
/>
) : (
<div className="h-full flex items-center justify-center text-gray-400">
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
<div className="text-center">
<Layout className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{__('Select a page from the sidebar to start editing')}</p>
<Layout className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p className="text-lg">{__('Select a page from the sidebar')}</p>
<p className="text-sm mt-2">{__('Edit pages and templates visually')}</p>
</div>
</div>
)}
</div>
)
}
{/* Right Column: Settings & Preview */}
<PageSettings
page={selectedPage}
section={selectedSection}
sections={structure?.sections || []}
onSectionUpdate={handleSectionUpdate}
isTemplate={selectedPage?.type === 'template'}
availableSources={pageData?.available_sources || []}
/>
</div>
{/* Right Column: Inspector Panel */}
{
currentPage && (
<InspectorPanel
page={currentPage}
selectedSection={selectedSection}
isCollapsed={inspectorCollapsed}
isTemplate={currentPage.type === 'template'}
availableSources={availableSources}
onToggleCollapse={() => setInspectorCollapsed(!inspectorCollapsed)}
onSectionPropChange={(propName, value) => {
if (selectedSectionId) {
updateSectionProp(selectedSectionId, propName, value);
}
}}
onLayoutChange={(layout) => {
if (selectedSectionId) {
updateSectionLayout(selectedSectionId, layout);
}
}}
onColorSchemeChange={(scheme) => {
if (selectedSectionId) {
updateSectionColorScheme(selectedSectionId, scheme);
}
}}
onSectionStylesChange={(styles) => {
if (selectedSectionId) {
updateSectionStyles(selectedSectionId, styles);
}
}}
onElementStylesChange={(fieldName, styles) => {
if (selectedSectionId) {
updateElementStyles(selectedSectionId, fieldName, styles);
}
}}
onDeleteSection={() => {
if (selectedSectionId) {
deleteSection(selectedSectionId);
}
}}
onSetAsSpaLanding={() => {
if (currentPage?.id) {
setSpaLandingMutation.mutate(currentPage.id);
}
}}
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
onDeletePage={handleDeletePage}
/>
)
}
</div >
{/* Create Page Modal */}
<CreatePageModal
< CreatePageModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
onCreated={(newPage) => {
queryClient.invalidateQueries({ queryKey: ['pages'] });
setSelectedPage(newPage);
}}
setCurrentPage(newPage);
}
}
/>
</div>
</div >
);
}

View File

@@ -0,0 +1,445 @@
import { create } from 'zustand';
// Simple ID generator (replaces uuid)
const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
export interface SectionProp {
type: 'static' | 'dynamic';
value?: any;
source?: string;
}
export interface SectionStyles {
backgroundColor?: string;
backgroundImage?: string;
backgroundOverlay?: number; // 0-100 opacity
paddingTop?: string;
paddingBottom?: string;
contentWidth?: 'full' | 'contained';
heightPreset?: string;
}
export interface ElementStyle {
color?: string;
fontSize?: string;
fontWeight?: string;
fontFamily?: 'primary' | 'secondary';
textAlign?: 'left' | 'center' | 'right';
// Image specific
objectFit?: 'cover' | 'contain' | 'fill';
backgroundColor?: string; // Wrapper BG
width?: string;
height?: string;
// Link specific
textDecoration?: 'none' | 'underline';
hoverColor?: string;
// Button/Box specific
borderColor?: string;
borderWidth?: string;
borderRadius?: string;
padding?: string;
}
export interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
styles?: SectionStyles;
elementStyles?: Record<string, ElementStyle>;
props: Record<string, SectionProp>;
}
export interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
url?: string;
isSpaLanding?: boolean;
}
interface PageEditorState {
// Current page/template being edited
currentPage: PageItem | null;
// Sections for the current page
sections: Section[];
// Selection & interaction
selectedSectionId: string | null;
hoveredSectionId: string | null;
// UI state
deviceMode: 'desktop' | 'mobile';
inspectorCollapsed: boolean;
hasUnsavedChanges: boolean;
isLoading: boolean;
// Available sources for dynamic fields (CPT templates)
availableSources: { value: string; label: string }[];
// Actions
setCurrentPage: (page: PageItem | null) => void;
setSections: (sections: Section[]) => void;
setSelectedSection: (id: string | null) => void;
setHoveredSection: (id: string | null) => void;
setDeviceMode: (mode: 'desktop' | 'mobile') => void;
setInspectorCollapsed: (collapsed: boolean) => void;
setAvailableSources: (sources: { value: string; label: string }[]) => void;
setIsLoading: (loading: boolean) => void;
// Section actions
addSection: (type: string, index?: number) => void;
deleteSection: (id: string) => void;
duplicateSection: (id: string) => void;
moveSection: (id: string, direction: 'up' | 'down') => void;
reorderSections: (sections: Section[]) => void;
updateSectionProp: (sectionId: string, propName: string, value: SectionProp) => void;
updateSectionLayout: (sectionId: string, layoutVariant: string) => void;
updateSectionColorScheme: (sectionId: string, colorScheme: string) => void;
updateSectionStyles: (sectionId: string, styles: Partial<SectionStyles>) => void;
updateElementStyles: (sectionId: string, fieldName: string, styles: Partial<ElementStyle>) => void;
// Page actions
setAsSpaLanding: () => Promise<void>;
unsetSpaLanding: () => Promise<void>;
// Persistence
markAsChanged: () => void;
markAsSaved: () => void;
reset: () => void;
savePage: () => Promise<void>;
}
// Default props for each section type
const DEFAULT_SECTION_PROPS: Record<string, Record<string, SectionProp>> = {
hero: {
title: { type: 'static', value: 'Welcome to Our Site' },
subtitle: { type: 'static', value: 'Discover amazing products and services' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: 'Get Started' },
cta_url: { type: 'static', value: '#' },
},
content: {
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
'image-text': {
title: { type: 'static', value: 'Section Title' },
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
'feature-grid': {
heading: { type: 'static', value: 'Our Features' },
features: { type: 'static', value: '' },
},
'cta-banner': {
title: { type: 'static', value: 'Ready to get started?' },
text: { type: 'static', value: 'Join thousands of happy customers today.' },
button_text: { type: 'static', value: 'Get Started' },
button_url: { type: 'static', value: '#' },
},
'contact-form': {
title: { type: 'static', value: 'Contact Us' },
webhook_url: { type: 'static', value: '' },
redirect_url: { type: 'static', value: '' },
},
};
// Define a SECTION_CONFIGS object based on DEFAULT_SECTION_PROPS for the new addSection logic
const SECTION_CONFIGS: Record<string, { defaultProps: Record<string, SectionProp>; defaultStyles?: SectionStyles }> = {
hero: { defaultProps: DEFAULT_SECTION_PROPS.hero, defaultStyles: { contentWidth: 'full' } },
content: { defaultProps: DEFAULT_SECTION_PROPS.content, defaultStyles: { contentWidth: 'full' } },
'image-text': { defaultProps: DEFAULT_SECTION_PROPS['image-text'], defaultStyles: { contentWidth: 'contained' } },
'feature-grid': { defaultProps: DEFAULT_SECTION_PROPS['feature-grid'], defaultStyles: { contentWidth: 'contained' } },
'cta-banner': { defaultProps: DEFAULT_SECTION_PROPS['cta-banner'], defaultStyles: { contentWidth: 'full' } },
'contact-form': { defaultProps: DEFAULT_SECTION_PROPS['contact-form'], defaultStyles: { contentWidth: 'contained' } },
};
export const usePageEditorStore = create<PageEditorState>((set, get) => ({
// Initial state
currentPage: null,
sections: [],
selectedSectionId: null,
hoveredSectionId: null,
deviceMode: 'desktop',
inspectorCollapsed: false,
hasUnsavedChanges: false,
isLoading: false,
availableSources: [],
// Setters
setCurrentPage: (currentPage) => set({ currentPage }),
setSections: (sections) => set({ sections, hasUnsavedChanges: true }),
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
setDeviceMode: (deviceMode) => set({ deviceMode }),
setInspectorCollapsed: (inspectorCollapsed) => set({ inspectorCollapsed }),
setAvailableSources: (availableSources) => set({ availableSources }),
setIsLoading: (isLoading) => set({ isLoading }),
// Section actions
addSection: (type, index) => {
const { sections } = get();
const sectionConfig = SECTION_CONFIGS[type];
if (!sectionConfig) return;
const newSection: Section = {
id: generateId(),
type,
props: { ...sectionConfig.defaultProps },
styles: { ...sectionConfig.defaultStyles }
};
const newSections = [...sections];
if (typeof index === 'number') {
newSections.splice(index, 0, newSection);
} else {
newSections.push(newSection);
}
set({ sections: newSections, hasUnsavedChanges: true });
// Select the new section
set({ selectedSectionId: newSection.id });
},
deleteSection: (id) => {
const { sections, selectedSectionId } = get();
const newSections = sections.filter(s => s.id !== id);
set({
sections: newSections,
hasUnsavedChanges: true,
selectedSectionId: selectedSectionId === id ? null : selectedSectionId
});
},
duplicateSection: (id) => {
const { sections } = get();
const index = sections.findIndex(s => s.id === id);
if (index === -1) return;
const section = sections[index];
const newSection: Section = {
...JSON.parse(JSON.stringify(section)), // Deep clone
id: generateId()
};
const newSections = [...sections];
newSections.splice(index + 1, 0, newSection);
set({ sections: newSections, hasUnsavedChanges: true });
},
moveSection: (id, direction) => {
const { sections } = get();
const index = sections.findIndex(s => s.id === id);
if (index === -1) return;
if (direction === 'up' && index > 0) {
const newSections = [...sections];
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true });
} else if (direction === 'down' && index < sections.length - 1) {
const newSections = [...sections];
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true });
}
},
reorderSections: (sections) => {
set({ sections, hasUnsavedChanges: true });
},
updateSectionProp: (sectionId, propName, value) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
props: {
...section.props,
[propName]: value
}
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateSectionLayout: (sectionId, layoutVariant) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
layoutVariant
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateSectionColorScheme: (sectionId, colorScheme) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
colorScheme
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateSectionStyles: (sectionId, styles) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
styles: {
...section.styles,
...styles
}
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateElementStyles: (sectionId, fieldName, styles) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
const newElementStyles = {
...section.elementStyles,
[fieldName]: {
...(section.elementStyles?.[fieldName] || {}),
...styles
}
};
return {
...section,
elementStyles: newElementStyles
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
// Page Actions
setAsSpaLanding: async () => {
const { currentPage } = get();
if (!currentPage || !currentPage.id) return;
try {
set({ isLoading: true });
// Call API to set page as SPA Landing
await fetch(`${(window as any).WNW_API.root}/pages/${currentPage.id}/set-as-spa-landing`, {
method: 'POST',
headers: {
'X-WP-Nonce': (window as any).WNW_API.nonce,
'Content-Type': 'application/json'
}
});
// Update local state - only update isSpaLanding, no other properties
set({
currentPage: {
...currentPage,
isSpaLanding: true
},
isLoading: false
});
} catch (error) {
console.error('Failed to set SPA landing page:', error);
set({ isLoading: false });
}
},
unsetSpaLanding: async () => {
const { currentPage } = get();
if (!currentPage) return;
try {
set({ isLoading: true });
// Call API to unset SPA Landing
await fetch(`${(window as any).WNW_API.root}/pages/unset-spa-landing`, {
method: 'POST',
headers: {
'X-WP-Nonce': (window as any).WNW_API.nonce,
'Content-Type': 'application/json'
}
});
// Update local state
set({
currentPage: {
...currentPage,
isSpaLanding: false
},
isLoading: false
});
} catch (error) {
console.error('Failed to unset SPA landing page:', error);
set({ isLoading: false });
}
},
// Persistence
markAsChanged: () => set({ hasUnsavedChanges: true }),
markAsSaved: () => set({ hasUnsavedChanges: false }),
savePage: async () => {
const { currentPage, sections } = get();
if (!currentPage) return;
try {
set({ isLoading: true });
const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`;
await fetch(`${(window as any).WNW_API.root}${endpoint}`, {
method: 'POST',
headers: {
'X-WP-Nonce': (window as any).WNW_API.nonce,
'Content-Type': 'application/json'
},
body: JSON.stringify({ sections })
});
set({
hasUnsavedChanges: false,
isLoading: false
});
} catch (error) {
console.error('Failed to save page:', error);
set({ isLoading: false });
}
},
reset: () =>
set({
currentPage: null,
sections: [],
selectedSectionId: null,
hoveredSectionId: null,
hasUnsavedChanges: false,
}),
}));