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

@@ -57,6 +57,7 @@
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.46.3", "@typescript-eslint/eslint-plugin": "^8.46.3",
@@ -2898,6 +2899,33 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.90.5", "version": "5.90.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",

View File

@@ -59,6 +59,7 @@
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.46.3", "@typescript-eslint/eslint-plugin": "^8.46.3",

View File

@@ -280,6 +280,7 @@ import AppearanceCart from '@/routes/Appearance/Cart';
import AppearanceCheckout from '@/routes/Appearance/Checkout'; import AppearanceCheckout from '@/routes/Appearance/Checkout';
import AppearanceThankYou from '@/routes/Appearance/ThankYou'; import AppearanceThankYou from '@/routes/Appearance/ThankYou';
import AppearanceAccount from '@/routes/Appearance/Account'; import AppearanceAccount from '@/routes/Appearance/Account';
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
import AppearancePages from '@/routes/Appearance/Pages'; import AppearancePages from '@/routes/Appearance/Pages';
import MarketingIndex from '@/routes/Marketing'; import MarketingIndex from '@/routes/Marketing';
import Newsletter from '@/routes/Marketing/Newsletter'; import Newsletter from '@/routes/Marketing/Newsletter';
@@ -628,6 +629,7 @@ function AppRoutes() {
<Route path="/appearance/checkout" element={<AppearanceCheckout />} /> <Route path="/appearance/checkout" element={<AppearanceCheckout />} />
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} /> <Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
<Route path="/appearance/account" element={<AppearanceAccount />} /> <Route path="/appearance/account" element={<AppearanceAccount />} />
<Route path="/appearance/menus" element={<AppearanceMenus />} />
<Route path="/appearance/pages" element={<AppearancePages />} /> <Route path="/appearance/pages" element={<AppearancePages />} />
{/* Marketing */} {/* Marketing */}

View File

@@ -75,7 +75,7 @@ export function BlockRenderer({
marginBottom: '24px' marginBottom: '24px'
}, },
hero: { hero: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)',
color: '#fff', color: '#fff',
borderRadius: '8px', borderRadius: '8px',
padding: '32px 40px', padding: '32px 40px',
@@ -100,7 +100,7 @@ export function BlockRenderer({
const buttonStyle: React.CSSProperties = block.style === 'solid' const buttonStyle: React.CSSProperties = block.style === 'solid'
? { ? {
display: 'inline-block', display: 'inline-block',
background: '#7f54b3', background: 'var(--wn-primary, #7f54b3)',
color: '#fff', color: '#fff',
padding: '14px 28px', padding: '14px 28px',
borderRadius: '6px', borderRadius: '6px',
@@ -110,9 +110,9 @@ export function BlockRenderer({
: { : {
display: 'inline-block', display: 'inline-block',
background: 'transparent', background: 'transparent',
color: '#7f54b3', color: 'var(--wn-secondary, #7f54b3)',
padding: '12px 26px', padding: '12px 26px',
border: '2px solid #7f54b3', border: '2px solid var(--wn-secondary, #7f54b3)',
borderRadius: '6px', borderRadius: '6px',
textDecoration: 'none', textDecoration: 'none',
fontWeight: 600, fontWeight: 600,

View File

@@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
if (block.type === 'card') { if (block.type === 'card') {
// Convert markdown to HTML for rich text editor // Convert markdown to HTML for rich text editor
const htmlContent = parseMarkdownBasics(block.content); const htmlContent = parseMarkdownBasics(block.content);
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
setEditingContent(htmlContent); setEditingContent(htmlContent);
setEditingCardType(block.cardType); setEditingCardType(block.cardType);
} else if (block.type === 'button') { } else if (block.type === 'button') {

View File

@@ -0,0 +1,77 @@
import React, { useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Image, Upload } from 'lucide-react';
import { __ } from '@/lib/i18n';
interface MediaUploaderProps {
onSelect: (url: string, id?: number) => void;
type?: 'image' | 'video' | 'audio' | 'file';
title?: string;
buttonText?: string;
className?: string;
children?: React.ReactNode;
}
export function MediaUploader({
onSelect,
type = 'image',
title = __('Select Image'),
buttonText = __('Use Image'),
className,
children
}: MediaUploaderProps) {
const frameRef = useRef<any>(null);
const openMediaModal = (e: React.MouseEvent) => {
e.preventDefault();
// Check if wp.media is available
const wp = (window as any).wp;
if (!wp || !wp.media) {
console.warn('WordPress media library not available');
return;
}
// Reuse existing frame
if (frameRef.current) {
frameRef.current.open();
return;
}
// Create new frame
frameRef.current = wp.media({
title,
button: {
text: buttonText,
},
library: {
type,
},
multiple: false,
});
// Handle selection
frameRef.current.on('select', () => {
const state = frameRef.current.state();
const selection = state.get('selection');
if (selection.length > 0) {
const attachment = selection.first().toJSON();
onSelect(attachment.url, attachment.id);
}
});
frameRef.current.open();
};
return (
<div onClick={openMediaModal} className={className}>
{children || (
<Button variant="outline" size="sm" type="button">
<Upload className="w-4 h-4 mr-2" />
{__('Select Image')}
</Button>
)}
</div>
);
}

View File

@@ -50,6 +50,8 @@ export function RichTextEditor({
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
}), }),
// ButtonExtension MUST come before Link to ensure buttons are parsed first
ButtonExtension,
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
HTMLAttributes: { HTMLAttributes: {
@@ -65,7 +67,6 @@ export function RichTextEditor({
class: 'max-w-full h-auto rounded', class: 'max-w-full h-auto rounded',
}, },
}), }),
ButtonExtension,
], ],
content, content,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {

View File

@@ -21,7 +21,7 @@ export interface Option {
/** What to render in the button/list. Can be a string or React node. */ /** What to render in the button/list. Can be a string or React node. */
label: React.ReactNode; label: React.ReactNode;
/** Optional text used for filtering. Falls back to string label or value. */ /** Optional text used for filtering. Falls back to string label or value. */
searchText?: string; triggerLabel?: React.ReactNode;
} }
interface Props { interface Props {
@@ -55,7 +55,7 @@ export function SearchableSelect({
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]); React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
return ( return (
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}> <Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@@ -65,7 +65,7 @@ export function SearchableSelect({
aria-disabled={disabled} aria-disabled={disabled}
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
> >
{selected ? selected.label : placeholder} {selected ? (selected.triggerLabel ?? selected.label) : placeholder}
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" /> <ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]", "relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className

View File

@@ -39,6 +39,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
return [ return [
{ {
tag: 'a[data-button]', tag: 'a[data-button]',
priority: 100, // Higher priority than Link extension (default 50)
getAttrs: (node: HTMLElement) => ({ getAttrs: (node: HTMLElement) => ({
text: node.getAttribute('data-text') || node.textContent || 'Click Here', text: node.getAttribute('data-text') || node.textContent || 'Click Here',
href: node.getAttribute('data-href') || node.getAttribute('href') || '#', href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
@@ -47,6 +48,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
}, },
{ {
tag: 'a.button', tag: 'a.button',
priority: 100,
getAttrs: (node: HTMLElement) => ({ getAttrs: (node: HTMLElement) => ({
text: node.textContent || 'Click Here', text: node.textContent || 'Click Here',
href: node.getAttribute('href') || '#', href: node.getAttribute('href') || '#',
@@ -55,6 +57,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
}, },
{ {
tag: 'a.button-outline', tag: 'a.button-outline',
priority: 100,
getAttrs: (node: HTMLElement) => ({ getAttrs: (node: HTMLElement) => ({
text: node.textContent || 'Click Here', text: node.textContent || 'Click Here',
href: node.getAttribute('href') || '#', href: node.getAttribute('href') || '#',

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, ReactNode } from 'react'; import React, { createContext, useContext, ReactNode, useEffect } from 'react';
interface AppContextType { interface AppContextType {
isStandalone: boolean; isStandalone: boolean;
@@ -16,6 +16,35 @@ export function AppProvider({
isStandalone: boolean; isStandalone: boolean;
exitFullscreen?: () => void; exitFullscreen?: () => void;
}) { }) {
useEffect(() => {
// Fetch and apply appearance settings (colors)
const loadAppearance = async () => {
try {
const restUrl = (window as any).WNW_CONFIG?.restUrl || '';
const response = await fetch(`${restUrl}/appearance/settings`);
if (response.ok) {
const result = await response.json();
// API returns { success: true, data: { general: { colors: {...} } } }
const colors = result.data?.general?.colors;
if (colors) {
const root = document.documentElement;
// Inject all color settings as CSS variables
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
if (colors.text) root.style.setProperty('--wn-text', colors.text);
if (colors.background) root.style.setProperty('--wn-background', colors.background);
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
}
}
} catch (e) {
console.error('Failed to load appearance settings', e);
}
};
loadAppearance();
}, []);
return ( return (
<AppContext.Provider value={{ isStandalone, exitFullscreen }}> <AppContext.Provider value={{ isStandalone, exitFullscreen }}>
{children} {children}

View File

@@ -68,8 +68,23 @@ export function htmlToMarkdown(html: string): string {
}).join('\n') + '\n\n'; }).join('\n') + '\n\n';
}); });
// Paragraphs - convert to double newlines // Paragraphs - preserve text-align by using placeholders
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n'); const alignedParagraphs: { [key: string]: string } = {};
let alignIndex = 0;
markdown = markdown.replace(/<p([^>]*)>(.*?)<\/p>/gis, (match, attrs, content) => {
// Check for text-align in style attribute
const alignMatch = attrs.match(/text-align:\s*(center|right)/i);
if (alignMatch) {
const align = alignMatch[1].toLowerCase();
// Use double-bracket placeholder that won't be matched by HTML regex
const placeholder = `[[ALIGN${alignIndex}]]`;
alignedParagraphs[placeholder] = `<p style="text-align: ${align};">${content}</p>`;
alignIndex++;
return placeholder + '\n\n';
}
// No alignment, convert to plain text
return `${content}\n\n`;
});
// Line breaks // Line breaks
markdown = markdown.replace(/<br\s*\/?>/gi, '\n'); markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
@@ -80,6 +95,11 @@ export function htmlToMarkdown(html: string): string {
// Remove remaining HTML tags // Remove remaining HTML tags
markdown = markdown.replace(/<[^>]+>/g, ''); markdown = markdown.replace(/<[^>]+>/g, '');
// Restore aligned paragraphs
Object.entries(alignedParagraphs).forEach(([placeholder, html]) => {
markdown = markdown.replace(placeholder, html);
});
// Clean up excessive newlines // Clean up excessive newlines
markdown = markdown.replace(/\n{3,}/g, '\n\n'); markdown = markdown.replace(/\n{3,}/g, '\n\n');

View File

@@ -43,6 +43,8 @@ export default function AppearanceGeneral() {
accent: '#3b82f6', accent: '#3b82f6',
text: '#111827', text: '#111827',
background: '#ffffff', background: '#ffffff',
gradientStart: '#9333ea', // purple-600 defaults
gradientEnd: '#3b82f6', // blue-500 defaults
}); });
useEffect(() => { useEffect(() => {
@@ -70,6 +72,8 @@ export default function AppearanceGeneral() {
accent: general.colors.accent || '#3b82f6', accent: general.colors.accent || '#3b82f6',
text: general.colors.text || '#111827', text: general.colors.text || '#111827',
background: general.colors.background || '#ffffff', background: general.colors.background || '#ffffff',
gradientStart: general.colors.gradientStart || '#9333ea',
gradientEnd: general.colors.gradientEnd || '#3b82f6',
}); });
} }
} }
@@ -345,18 +349,18 @@ export default function AppearanceGeneral() {
> >
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(colors).map(([key, value]) => ( {Object.entries(colors).map(([key, value]) => (
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1)} htmlFor={`color-${key}`}> <SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')} htmlFor={`color-${key}`}>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id={`color-${key}`} id={`color-${key}`}
type="color" type="color"
value={value} value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })} onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
className="w-20 h-10 cursor-pointer" className="w-20 h-10 cursor-pointer"
/> />
<Input <Input
type="text" type="text"
value={value} value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })} onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
className="flex-1 font-mono" className="flex-1 font-mono"
/> />

View File

@@ -0,0 +1,495 @@
import React, { useState, useEffect } from 'react';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Plus, GripVertical, Trash2, Link as LinkIcon, FileText, Check, AlertCircle, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { SearchableSelect } from '@/components/ui/searchable-select';
// Types
interface Page {
id: number;
title: string;
slug: string;
is_woonoow_page?: boolean;
is_store_page?: boolean;
}
interface MenuItem {
id: string;
label: string;
type: 'page' | 'custom';
value: string;
target: '_self' | '_blank';
}
interface MenuSettings {
primary: MenuItem[];
mobile: MenuItem[];
}
// Sortable Item Component
function SortableMenuItem({
item,
onRemove,
onUpdate,
pages
}: {
item: MenuItem;
onRemove: (id: string) => void;
onUpdate: (id: string, updates: Partial<MenuItem>) => void;
pages: Page[];
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const [isEditing, setIsEditing] = useState(false);
return (
<div
ref={setNodeRef}
style={style}
className={`bg-white border rounded-lg mb-2 shadow-sm ${isEditing ? 'ring-2 ring-primary ring-offset-1' : ''}`}
>
<div className="flex items-center p-3 gap-3">
<div {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
<GripVertical className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0" onClick={() => setIsEditing(!isEditing)}>
<div className="flex items-center gap-2 font-medium truncate">
{item.type === 'page' ? <FileText className="w-4 h-4 text-blue-500" /> : <LinkIcon className="w-4 h-4 text-green-500" />}
{item.type === 'page' ? (
(() => {
const page = pages.find(p => p.slug === item.value);
if (page?.is_store_page) {
return <span className="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-[10px] font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Store</span>;
}
if (item.value === '/' || page?.is_woonoow_page) {
return <span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-[10px] font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>;
}
return <span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">WP</span>;
})()
) : (
<span className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">Custom</span>
)}
</div>
<div className="text-xs text-gray-500 truncate">
{item.type === 'page' ? `Page: /${item.value}` : `URL: ${item.value}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-red-500" onClick={() => onRemove(item.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{isEditing && (
<div className="p-4 border-t bg-gray-50 rounded-b-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">Label</Label>
<Input
value={item.label}
onChange={(e) => onUpdate(item.id, { label: e.target.value })}
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Target</Label>
<Select
value={item.target}
onValueChange={(val: any) => onUpdate(item.id, { target: val })}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="_self">Same Tab</SelectItem>
<SelectItem value="_blank">New Tab</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{item.type === 'custom' && (
<div className="space-y-2">
<Label className="text-xs">URL</Label>
<Input
value={item.value}
onChange={(e) => onUpdate(item.id, { value: e.target.value })}
className="h-8 text-sm font-mono"
/>
</div>
)}
</div>
)}
</div>
);
}
export default function MenuEditor() {
const [menus, setMenus] = useState<MenuSettings>({ primary: [], mobile: [] });
const [activeTab, setActiveTab] = useState<'primary' | 'mobile'>('primary');
const [loading, setLoading] = useState(true);
const [pages, setPages] = useState<Page[]>([]);
const [spaPageId, setSpaPageId] = useState<number>(0);
// New Item State
const [newItemType, setNewItemType] = useState<'page' | 'custom'>('page');
const [newItemLabel, setNewItemLabel] = useState('');
const [newItemValue, setNewItemValue] = useState('');
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const [settingsRes, pagesRes] = await Promise.all([
api.get('/appearance/settings'),
api.get('/pages/list')
]);
const settings = settingsRes.data;
if (settings.menus) {
setMenus(settings.menus);
} else {
// Default seeding if empty
setMenus({
primary: [
{ id: 'home', label: 'Home', type: 'page', value: '/', target: '_self' },
{ id: 'shop', label: 'Shop', type: 'page', value: 'shop', target: '_self' }
],
mobile: []
});
}
if (settings.general?.spa_page) {
setSpaPageId(parseInt(settings.general.spa_page));
}
if (pagesRes.success) {
setPages(pagesRes.data);
}
setLoading(false);
} catch (error) {
console.error('Failed to load menu data', error);
toast.error('Failed to load menu data');
setLoading(false);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
setMenus((prev) => {
const list = prev[activeTab];
const oldIndex = list.findIndex((item) => item.id === active.id);
const newIndex = list.findIndex((item) => item.id === over?.id);
return {
...prev,
[activeTab]: arrayMove(list, oldIndex, newIndex),
};
});
}
};
const addItem = () => {
if (!newItemLabel) {
toast.error('Label is required');
return;
}
if (!newItemValue) {
toast.error('Destination is required');
return;
}
const newItem: MenuItem = {
id: Math.random().toString(36).substr(2, 9),
label: newItemLabel,
type: newItemType,
value: newItemValue,
target: '_self'
};
setMenus(prev => ({
...prev,
[activeTab]: [...prev[activeTab], newItem]
}));
// Reset form
setNewItemLabel('');
if (newItemType === 'custom') setNewItemValue('');
toast.success('Item added');
};
const removeItem = (id: string) => {
setMenus(prev => ({
...prev,
[activeTab]: prev[activeTab].filter(item => item.id !== id)
}));
};
const updateItem = (id: string, updates: Partial<MenuItem>) => {
setMenus(prev => ({
...prev,
[activeTab]: prev[activeTab].map(item => item.id === id ? { ...item, ...updates } : item)
}));
};
const handleSave = async () => {
try {
await api.post('/appearance/menus', { menus });
toast.success('Menus saved successfully');
} catch (error) {
toast.error('Failed to save menus');
}
};
if (loading) {
return <div className="p-8 flex justify-center"><div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div></div>;
}
return (
<SettingsLayout
title="Menu Editor"
description="Manage your store's navigation menus"
onSave={handleSave}
isLoading={loading}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Col: Add Items */}
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-lg">Add Items</CardTitle>
<CardDescription>Add pages or custom links</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Type</Label>
<Tabs value={newItemType} onValueChange={(v: any) => setNewItemType(v)} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="page">Page</TabsTrigger>
<TabsTrigger value="custom">Custom URL</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="space-y-2">
<Label>Label</Label>
<Input
placeholder="e.g. Shop"
value={newItemLabel}
onChange={(e) => setNewItemLabel(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Destination</Label>
{newItemType === 'page' ? (
<SearchableSelect
value={newItemValue}
onChange={setNewItemValue}
options={[
{
value: '/',
label: (
<div className="flex items-center justify-between w-full">
<span className="flex items-center gap-2"><Home className="w-4 h-4" /> Home</span>
<span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>
</div>
),
triggerLabel: (
<div className="flex items-center justify-between w-full">
<span className="flex items-center gap-2"><Home className="w-4 h-4" /> Home</span>
<span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>
</div>
),
searchText: 'Home'
},
...pages.filter(p => p.id !== spaPageId).map(page => {
const Badge = () => {
if (page.is_store_page) {
return <span className="ml-2 inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Store</span>;
}
if (page.is_woonoow_page) {
return <span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>;
}
return <span className="ml-2 inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">WP</span>;
};
return {
value: page.slug,
label: (
<div className="flex items-center justify-between w-full">
<div className="flex flex-col overflow-hidden">
<span className="truncate">{page.title}</span>
<span className="text-[10px] text-gray-400 font-mono truncate">/{page.slug}</span>
</div>
<Badge />
</div>
),
triggerLabel: (
<div className="flex items-center justify-between w-full">
<span className="truncate">{page.title}</span>
<Badge />
</div>
),
searchText: `${page.title} ${page.slug}`
};
})
]}
placeholder="Select a page"
className="w-full"
/>
) : (
<Input
placeholder="https://"
value={newItemValue}
onChange={(e) => setNewItemValue(e.target.value)}
/>
)}
</div>
<Button className="w-full" variant="outline" onClick={addItem}>
<Plus className="w-4 h-4 mr-2" /> Add to Menu
</Button>
</CardContent>
</Card>
{/* Right Col: Menu Structure */}
<div className="md:col-span-2 space-y-6">
<Tabs value={activeTab} onValueChange={(v: any) => setActiveTab(v)}>
<TabsList className="w-full justify-start">
<TabsTrigger value="primary">Primary Menu</TabsTrigger>
<TabsTrigger value="mobile">Mobile Menu (Optional)</TabsTrigger>
</TabsList>
<TabsContent value="primary" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Menu Structure</CardTitle>
<CardDescription>Drag and drop to reorder items</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={menus.primary.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
{menus.primary.length === 0 ? (
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
<AlertCircle className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No items in menu</p>
</div>
) : (
menus.primary.map((item) => (
<SortableMenuItem
key={item.id}
item={item}
onRemove={removeItem}
onUpdate={updateItem}
pages={pages}
/>
))
)}
</SortableContext>
</DndContext>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mobile" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Mobile Menu Structure</CardTitle>
<CardDescription>
Leave empty to use Primary Menu automatically.
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={menus.mobile.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
{menus.mobile.length === 0 ? (
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
<p>Using Primary Menu</p>
</div>
) : (
menus.mobile.map((item) => (
<SortableMenuItem
key={item.id}
item={item}
onRemove={removeItem}
onUpdate={updateItem}
pages={pages}
/>
))
)}
</SortableContext>
</DndContext>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</SettingsLayout>
);
}

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 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 { api } from '@/lib/api';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button'; 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 [pageType, setPageType] = useState<'page' | 'template'>('page');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [slug, setSlug] = useState(''); const [slug, setSlug] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
// Prevent double submission // Prevent double submission
const isSubmittingRef = useRef(false); const isSubmittingRef = useRef(false);
@@ -42,9 +43,18 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
// Get site URL from WordPress config // Get site URL from WordPress config
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin; 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 // Create page mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: { title: string; slug: string }) => { mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
// Guard against double submission // Guard against double submission
if (isSubmittingRef.current) { if (isSubmittingRef.current) {
throw new Error('Request already in progress'); throw new Error('Request already in progress');
@@ -53,7 +63,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
try { try {
// api.post returns JSON directly (not wrapped in { data: ... }) // 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 return response; // Return response directly, not response.data
} finally { } finally {
// Reset after a delay to prevent race conditions // Reset after a delay to prevent race conditions
@@ -74,6 +88,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
onOpenChange(false); onOpenChange(false);
setTitle(''); setTitle('');
setSlug(''); setSlug('');
setSelectedTemplateId('blank');
} }
}, },
onError: (error: any) => { onError: (error: any) => {
@@ -105,7 +120,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
return; return;
} }
if (pageType === 'page' && title && slug) { 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(''); setTitle('');
setSlug(''); setSlug('');
setPageType('page'); setPageType('page');
setSelectedTemplateId('blank');
isSubmittingRef.current = false; isSubmittingRef.current = false;
} }
}, [open]); }, [open]);
@@ -123,7 +139,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{__('Create New Page')}</DialogTitle> <DialogTitle>{__('Create New Page')}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -133,8 +149,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
<div className="space-y-6 py-4 px-1"> <div className="space-y-6 py-4 px-1">
{/* Page Type Selection */} {/* Page Type Selection */}
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')}> <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 hover:bg-accent/50 transition-colors"> <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" /> <RadioGroupItem value="page" id="page" className="mt-1" />
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium"> <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> </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 /> <RadioGroupItem value="template" id="template" className="mt-1" disabled />
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium"> <Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
@@ -163,7 +182,8 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
{/* Page Details */} {/* Page Details */}
{pageType === 'page' && ( {pageType === 'page' && (
<div className="space-y-4"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">{__('Page Title')}</Label> <Label htmlFor="title">{__('Page Title')}</Label>
<Input <Input
@@ -174,7 +194,6 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
disabled={createMutation.isPending} disabled={createMutation.isPending}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="slug">{__('URL Slug')}</Label> <Label htmlFor="slug">{__('URL Slug')}</Label>
<Input <Input
@@ -184,11 +203,40 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
placeholder={__('e.g., about-us')} placeholder={__('e.g., about-us')}
disabled={createMutation.isPending} disabled={createMutation.isPending}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground truncate">
{__('URL will be: ')}<span className="font-mono text-primary">{siteUrl}/{slug || 'page-slug'}</span> <span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
</p> </p>
</div> </div>
</div> </div>
<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>
)} )}
</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 React from 'react';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FileText, Layout, Loader2 } from 'lucide-react'; import { FileText, Layout, Loader2, Home } from 'lucide-react';
interface PageItem { interface PageItem {
id?: number; id?: number;
@@ -51,14 +51,19 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
key={`page-${page.id}`} key={`page-${page.id}`}
onClick={() => onSelectPage(page)} onClick={() => onSelectPage(page)}
className={cn( 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', 'hover:bg-gray-100',
selectedPage?.id === page.id && selectedPage?.type === 'page' selectedPage?.id === page.id && selectedPage?.type === 'page'
? 'bg-primary/10 text-primary font-medium' ? 'bg-primary/10 text-primary font-medium'
: 'text-gray-700' : '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> </button>
)) ))
)} )}

View File

@@ -31,6 +31,17 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
interface Section { interface Section {
id: string; id: string;
@@ -158,18 +169,36 @@ function SortableSectionCard({
> >
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</Button> </Button>
<Button <AlertDialog>
variant="ghost" <AlertDialogTrigger asChild>
size="icon" <button
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
onClick={() => { title="Delete"
if (confirm(__('Delete this section?'))) {
onDelete();
}
}}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </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>
</div> </div>
</Card> </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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
import { Plus, FileText, Layout, Trash2, Eye, Settings } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { PageSidebar } from './components/PageSidebar'; import { PageSidebar } from './components/PageSidebar';
import { SectionEditor } from './components/SectionEditor'; import { CanvasRenderer } from './components/CanvasRenderer';
import { PageSettings } from './components/PageSettings'; import { InspectorPanel } from './components/InspectorPanel';
import { CreatePageModal } from './components/CreatePageModal'; import { CreatePageModal } from './components/CreatePageModal';
import { usePageEditorStore, Section } from './store/usePageEditorStore';
// Types // Types
interface PageItem { interface PageItem {
@@ -23,75 +23,109 @@ interface PageItem {
icon?: string; icon?: string;
has_template?: boolean; has_template?: boolean;
permalink_base?: string; permalink_base?: string;
} isFrontPage?: boolean;
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
}
interface PageStructure {
type: 'page' | 'template';
sections: Section[];
updated_at?: string;
} }
export default function AppearancePages() { export default function AppearancePages() {
const queryClient = useQueryClient(); 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 [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 // Fetch all pages and templates
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({ const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
queryKey: ['pages'], queryKey: ['pages'],
queryFn: async () => { queryFn: async () => {
// api.get returns JSON directly (not wrapped in { data: ... })
const response = await api.get('/pages'); 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 // Fetch selected page/template structure
const { data: pageData, isLoading: pageLoading } = useQuery({ 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 () => { queryFn: async () => {
if (!selectedPage) return null; if (!currentPage) return null;
const endpoint = selectedPage.type === 'page' const endpoint = currentPage.type === 'page'
? `/pages/${selectedPage.slug}` ? `/pages/${currentPage.slug}`
: `/templates/${selectedPage.cpt}`; : `/templates/${currentPage.cpt}`;
// api.get returns JSON directly
const response = await api.get(endpoint); 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(() => { useEffect(() => {
if (pageData?.structure) { if (pageData?.structure?.sections) {
setStructure(pageData.structure); setSections(pageData.structure.sections);
setHasUnsavedChanges(false); 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 // Save mutation
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!selectedPage || !structure) return; if (!currentPage) return;
const endpoint = selectedPage.type === 'page' const endpoint = currentPage.type === 'page'
? `/pages/${selectedPage.slug}` ? `/pages/${currentPage.slug}`
: `/templates/${selectedPage.cpt}`; : `/templates/${currentPage.cpt}`;
return api.post(endpoint, { sections: structure.sections }); return api.post(endpoint, { sections });
}, },
onSuccess: () => { onSuccess: () => {
toast.success(__('Page saved successfully')); toast.success(__('Page saved successfully'));
setHasUnsavedChanges(false); markAsSaved();
queryClient.invalidateQueries({ queryKey: ['page-structure'] }); queryClient.invalidateQueries({ queryKey: ['page-structure'] });
}, },
onError: () => { onError: () => {
@@ -99,166 +133,244 @@ export default function AppearancePages() {
}, },
}); });
// Handle section update // Delete mutation
const handleSectionUpdate = (updatedSection: Section) => { const deleteMutation = useMutation({
if (!structure) return; mutationFn: async (id: number) => {
const newSections = structure.sections.map(s => return api.del(`/pages/${id}`);
s.id === updatedSection.id ? updatedSection : s },
); onSuccess: () => {
setStructure({ ...structure, sections: newSections }); toast.success(__('Page deleted successfully'));
setSelectedSection(updatedSection); markAsSaved(); // Clear unsaved flag
setHasUnsavedChanges(true); setCurrentPage(null);
}; queryClient.invalidateQueries({ queryKey: ['pages'] });
},
// Add new section onError: () => {
const handleAddSection = (sectionType: string) => { toast.error(__('Failed to delete page'));
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 });
} }
const newSection: Section = { },
id: `section-${Date.now()}`, onError: () => {
type: sectionType, toast.error(__('Failed to set SPA Landing Page'));
layoutVariant: 'default', },
colorScheme: 'default',
props: {},
};
setStructure(prev => ({
...prev!,
sections: [...(prev?.sections || []), newSection],
}));
setSelectedSection(newSection);
setHasUnsavedChanges(true);
};
// Delete section
const handleDeleteSection = (sectionId: string) => {
if (!structure) return;
setStructure({
...structure,
sections: structure.sections.filter(s => s.id !== sectionId),
}); });
if (selectedSection?.id === sectionId) {
// 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
});
} else {
setCurrentPage(page);
};
setSelectedSection(null); setSelectedSection(null);
};
const handleDiscard = () => {
if (pageData?.structure?.sections) {
setSections(pageData.structure.sections);
markAsSaved();
toast.success(__('Changes discarded'));
} }
setHasUnsavedChanges(true);
}; };
// Move section const handleDeletePage = () => {
const handleMoveSection = (sectionId: string, direction: 'up' | 'down') => { if (!currentPage || !currentPage.id) return;
if (!structure) return;
const index = structure.sections.findIndex(s => s.id === sectionId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1; if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
if (newIndex < 0 || newIndex >= structure.sections.length) return; deleteMutation.mutate(currentPage.id);
}
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);
}; };
return ( 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 */} {/* 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> <div>
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1> <h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{selectedPage ? selectedPage.title : __('Select a page to edit')} {currentPage ? currentPage.title : __('Select a page to edit')}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <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 && ( {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 <Button
variant="outline" variant="outline"
size="sm"
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
{__('Create Page')} {__('Create Page')}
</Button> </Button>
<Button <Button
size="sm"
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
disabled={!hasUnsavedChanges || saveMutation.isPending} disabled={!hasUnsavedChanges || saveMutation.isPending}
> >
{saveMutation.isPending ? __('Saving...') : __('Save Changes')} <Save className="w-4 h-4 mr-2" />
{saveMutation.isPending ? __('Saving...') : __('Save')}
</Button> </Button>
</div> </div>
</div> </div >
{/* 3-Column Layout */} {/* 3-Column Layout: Sidebar | Canvas | Inspector */}
<div className="flex-1 flex overflow-hidden"> < div className="flex-1 flex overflow-hidden" >
{/* Left Column: Pages List */} {/* Left Column: Pages List */}
<PageSidebar < PageSidebar
pages={pages} pages={pages}
selectedPage={selectedPage} selectedPage={currentPage}
onSelectPage={(page) => { onSelectPage={handleSelectPage}
if (hasUnsavedChanges) {
if (!confirm(__('You have unsaved changes. Continue?'))) return;
}
setSelectedPage(page);
setSelectedSection(null);
}}
isLoading={pagesLoading} isLoading={pagesLoading}
/> />
{/* Center Column: Section Editor */} {/* Center Column: Canvas Renderer */}
<div className="flex-1 bg-gray-50 overflow-y-auto p-6"> {
{selectedPage ? ( currentPage ? (
<SectionEditor <CanvasRenderer
sections={structure?.sections || []} sections={sections}
selectedSection={selectedSection} selectedSectionId={selectedSectionId}
deviceMode={deviceMode}
onSelectSection={setSelectedSection} onSelectSection={setSelectedSection}
onAddSection={handleAddSection} onAddSection={addSection}
onDeleteSection={handleDeleteSection} onDeleteSection={deleteSection}
onMoveSection={handleMoveSection} onDuplicateSection={duplicateSection}
onReorderSections={handleReorderSections} onMoveSection={moveSection}
isTemplate={selectedPage.type === 'template'} onReorderSections={reorderSections}
cpt={selectedPage.cpt} onDeviceModeChange={setDeviceMode}
isLoading={pageLoading}
/> />
) : ( ) : (
<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"> <div className="text-center">
<Layout className="w-12 h-12 mx-auto mb-4 opacity-50" /> <Layout className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{__('Select a page from the sidebar to start editing')}</p> <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> </div>
)} )
</div> }
{/* Right Column: Settings & Preview */} {/* Right Column: Inspector Panel */}
<PageSettings {
page={selectedPage} currentPage && (
section={selectedSection} <InspectorPanel
sections={structure?.sections || []} page={currentPage}
onSectionUpdate={handleSectionUpdate} selectedSection={selectedSection}
isTemplate={selectedPage?.type === 'template'} isCollapsed={inspectorCollapsed}
availableSources={pageData?.available_sources || []} 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> )
}
</div >
{/* Create Page Modal */} {/* Create Page Modal */}
<CreatePageModal < CreatePageModal
open={showCreateModal} open={showCreateModal}
onOpenChange={setShowCreateModal} onOpenChange={setShowCreateModal}
onCreated={(newPage) => { onCreated={(newPage) => {
queryClient.invalidateQueries({ queryKey: ['pages'] }); 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,
}),
}));

View File

@@ -10,7 +10,8 @@ import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/
import { CodeEditor } from '@/components/ui/code-editor'; import { CodeEditor } from '@/components/ui/code-editor';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Eye, Edit, RotateCcw, FileText } from 'lucide-react'; import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { markdownToHtml } from '@/lib/markdown-utils'; import { markdownToHtml } from '@/lib/markdown-utils';
@@ -38,12 +39,22 @@ export default function EditTemplate() {
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown) const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
const [activeTab, setActiveTab] = useState('preview'); const [activeTab, setActiveTab] = useState('preview');
// Fetch email customization settings // Send Test Email state
const [testEmailDialogOpen, setTestEmailDialogOpen] = useState(false);
const [testEmail, setTestEmail] = useState('');
// Fetch email customization settings (for non-color settings like logo, footer, social links)
const { data: emailSettings } = useQuery({ const { data: emailSettings } = useQuery({
queryKey: ['email-settings'], queryKey: ['email-settings'],
queryFn: () => api.get('/notifications/email-settings'), queryFn: () => api.get('/notifications/email-settings'),
}); });
// Fetch appearance settings for unified colors
const { data: appearanceSettings } = useQuery({
queryKey: ['appearance-settings'],
queryFn: () => api.get('/appearance/settings'),
});
// Fetch template // Fetch template
const { data: template, isLoading, error } = useQuery({ const { data: template, isLoading, error } = useQuery({
queryKey: ['notification-template', eventId, channelId, recipientType], queryKey: ['notification-template', eventId, channelId, recipientType],
@@ -114,6 +125,32 @@ export default function EditTemplate() {
} }
}; };
// Send test email mutation
const sendTestMutation = useMutation({
mutationFn: async (email: string) => {
return api.post(`/notifications/templates/${eventId}/${channelId}/send-test`, {
email,
recipient: recipientType,
});
},
onSuccess: (data: any) => {
toast.success(data.message || __('Test email sent successfully'));
setTestEmailDialogOpen(false);
setTestEmail('');
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to send test email'));
},
});
const handleSendTest = () => {
if (!testEmail || !testEmail.includes('@')) {
toast.error(__('Please enter a valid email address'));
return;
}
sendTestMutation.mutate(testEmail);
};
// Visual mode: Update blocks → Markdown (source of truth) // Visual mode: Update blocks → Markdown (source of truth)
const handleBlocksChange = (newBlocks: EmailBlock[]) => { const handleBlocksChange = (newBlocks: EmailBlock[]) => {
setBlocks(newBlocks); setBlocks(newBlocks);
@@ -288,14 +325,15 @@ export default function EditTemplate() {
} }
}); });
// Get email settings for preview // Get email settings for preview - use UNIFIED appearance settings for colors
const settings = emailSettings || {}; const settings = emailSettings || {};
const primaryColor = settings.primary_color || '#7f54b3'; const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
const secondaryColor = settings.secondary_color || '#7f54b3'; const primaryColor = appearColors.primary || '#7f54b3';
const heroGradientStart = settings.hero_gradient_start || '#667eea'; const secondaryColor = appearColors.secondary || '#7f54b3';
const heroGradientEnd = settings.hero_gradient_end || '#764ba2'; const heroGradientStart = appearColors.gradientStart || '#667eea';
const heroTextColor = settings.hero_text_color || '#ffffff'; const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
const buttonTextColor = settings.button_text_color || '#ffffff'; const heroTextColor = '#ffffff'; // Always white on gradient
const buttonTextColor = '#ffffff'; // Always white on primary
const bodyBgColor = settings.body_bg_color || '#f8f8f8'; const bodyBgColor = settings.body_bg_color || '#f8f8f8';
const socialIconColor = settings.social_icon_color || 'white'; const socialIconColor = settings.social_icon_color || 'white';
const logoUrl = settings.logo_url || ''; const logoUrl = settings.logo_url || '';
@@ -307,10 +345,11 @@ export default function EditTemplate() {
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString()); const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
// Generate social icons HTML with PNG images // Generate social icons HTML with PNG images
// Get plugin URL from config, with fallback
const pluginUrl = const pluginUrl =
(window as any).woonoowData?.pluginUrl || (window as any).woonoowData?.pluginUrl ||
(window as any).WNW_CONFIG?.pluginUrl || (window as any).WNW_CONFIG?.pluginUrl ||
''; '/wp-content/plugins/woonoow/';
const socialIconsHtml = socialLinks.length > 0 ? ` const socialIconsHtml = socialLinks.length > 0 ? `
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
${socialLinks.map((link: any) => ` ${socialLinks.map((link: any) => `
@@ -414,6 +453,7 @@ export default function EditTemplate() {
} }
return ( return (
<>
<SettingsLayout <SettingsLayout
title={template.event_label || __('Edit Template')} title={template.event_label || __('Edit Template')}
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`} description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
@@ -447,6 +487,16 @@ export default function EditTemplate() {
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{__('Reset to Default')}</span> <span className="hidden sm:inline">{__('Reset to Default')}</span>
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => setTestEmailDialogOpen(true)}
className="gap-2"
title={__('Send Test')}
>
<Send className="h-4 w-4" />
<span className="hidden sm:inline">{__('Send Test')}</span>
</Button>
</div> </div>
} }
> >
@@ -537,5 +587,41 @@ export default function EditTemplate() {
</CardContent> </CardContent>
</Card> </Card>
</SettingsLayout> </SettingsLayout>
{/* Send Test Email Dialog */}
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Send Test Email')}</DialogTitle>
<DialogDescription>
{__('Send a test email with sample data to verify the template looks correct.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="test-email">{__('Email Address')}</Label>
<Input
id="test-email"
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="you@example.com"
/>
<p className="text-xs text-muted-foreground">
{__('The subject will be prefixed with [TEST]')}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={handleSendTest} disabled={sendTestMutation.isPending}>
{sendTestMutation.isPending ? __('Sending...') : __('Send Test')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }

View File

@@ -219,191 +219,23 @@ export default function EmailCustomization() {
} }
> >
<div className="space-y-6"> <div className="space-y-6">
{/* Brand Colors */} {/* Unified Colors Notice */}
<SettingsCard <SettingsCard
title={__('Brand Colors')} title={__('Brand Colors')}
description={__('Set your primary and secondary brand colors for buttons and accents')} description={__('Colors for buttons, gradients, and accents in emails')}
> >
<div className="grid gap-6 sm:grid-cols-2"> <div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="space-y-2"> <p className="text-sm text-blue-900 dark:text-blue-100">
<Label htmlFor="primary_color">{__('Primary Color')}</Label> <strong>{__('Colors are now unified!')}</strong>{' '}
<div className="flex gap-2"> {__('Email colors (buttons, gradients) now use the same colors as your storefront for consistent branding.')}
<Input
id="primary_color"
type="color"
value={formData.primary_color}
onChange={(e) => handleChange('primary_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.primary_color}
onChange={(e) => handleChange('primary_color', e.target.value)}
placeholder="#7f54b3"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Used for primary buttons and main accents')}
</p> </p>
</div> <p className="text-sm text-blue-900 dark:text-blue-100 mt-2">
{__('To change colors, go to')}{' '}
<div className="space-y-2"> <a href="#/appearance/general" className="font-medium underline hover:no-underline">
<Label htmlFor="secondary_color">{__('Secondary Color')}</Label> {__('Appearance → General → Colors')}
<div className="flex gap-2"> </a>
<Input
id="secondary_color"
type="color"
value={formData.secondary_color}
onChange={(e) => handleChange('secondary_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.secondary_color}
onChange={(e) => handleChange('secondary_color', e.target.value)}
placeholder="#7f54b3"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Used for outline buttons and borders')}
</p> </p>
</div> </div>
</div>
</SettingsCard>
{/* Hero Card Gradient */}
<SettingsCard
title={__('Hero Card Gradient')}
description={__('Customize the gradient colors for hero/success card backgrounds')}
>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hero_gradient_start">{__('Gradient Start')}</Label>
<div className="flex gap-2">
<Input
id="hero_gradient_start"
type="color"
value={formData.hero_gradient_start}
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_gradient_start}
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
placeholder="#667eea"
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="hero_gradient_end">{__('Gradient End')}</Label>
<div className="flex gap-2">
<Input
id="hero_gradient_end"
type="color"
value={formData.hero_gradient_end}
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_gradient_end}
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
placeholder="#764ba2"
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="hero_text_color">{__('Text Color')}</Label>
<div className="flex gap-2">
<Input
id="hero_text_color"
type="color"
value={formData.hero_text_color}
onChange={(e) => handleChange('hero_text_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_text_color}
onChange={(e) => handleChange('hero_text_color', e.target.value)}
placeholder="#ffffff"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Text and heading color for hero cards (usually white)')}
</p>
</div>
{/* Preview */}
<div className="mt-4 p-6 rounded-lg text-center" style={{
background: `linear-gradient(135deg, ${formData.hero_gradient_start} 0%, ${formData.hero_gradient_end} 100%)`
}}>
<h3 className="text-xl font-bold mb-2" style={{ color: formData.hero_text_color }}>{__('Preview')}</h3>
<p className="text-sm opacity-90" style={{ color: formData.hero_text_color }}>{__('This is how your hero cards will look')}</p>
</div>
</SettingsCard>
{/* Button Styling */}
<SettingsCard
title={__('Button Styling')}
description={__('Customize button text color and appearance')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="button_text_color">{__('Button Text Color')}</Label>
<div className="flex gap-2">
<Input
id="button_text_color"
type="color"
value={formData.button_text_color}
onChange={(e) => handleChange('button_text_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.button_text_color}
onChange={(e) => handleChange('button_text_color', e.target.value)}
placeholder="#ffffff"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Text color for buttons (usually white for dark buttons)')}
</p>
</div>
{/* Button Preview */}
<div className="flex gap-3 flex-wrap">
<button
className="px-6 py-3 rounded-lg font-medium"
style={{
backgroundColor: formData.primary_color,
color: formData.button_text_color,
}}
>
{__('Primary Button')}
</button>
<button
className="px-6 py-3 rounded-lg font-medium border-2"
style={{
borderColor: formData.secondary_color,
color: formData.secondary_color,
backgroundColor: 'transparent',
}}
>
{__('Secondary Button')}
</button>
</div>
</div>
</SettingsCard> </SettingsCard>
{/* Email Background & Social Icons */} {/* Email Background & Social Icons */}

View File

@@ -22,5 +22,5 @@ module.exports = {
borderRadius: { lg: "12px", md: "10px", sm: "8px" } borderRadius: { lg: "12px", md: "10px", sm: "8px" }
} }
}, },
plugins: [require("tailwindcss-animate")] plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
}; };

View File

@@ -56,22 +56,42 @@ const getAppearanceSettings = () => {
return (window as any).woonoowCustomer?.appearanceSettings || {}; return (window as any).woonoowCustomer?.appearanceSettings || {};
}; };
// Get initial route from data attribute (set by PHP based on SPA mode) // Get initial route from data attribute or derive from SPA mode
const getInitialRoute = () => { const getInitialRoute = () => {
const appEl = document.getElementById('woonoow-customer-app'); const appEl = document.getElementById('woonoow-customer-app');
const initialRoute = appEl?.getAttribute('data-initial-route'); const initialRoute = appEl?.getAttribute('data-initial-route');
return initialRoute || '/shop'; // Default to shop if not specified if (initialRoute) return initialRoute;
// Derive from SPA mode if no explicit route
const spaMode = (window as any).woonoowCustomer?.spaMode || 'full';
if (spaMode === 'checkout_only') return '/checkout';
return '/shop'; // Default for full mode
};
// Get front page slug from config
const getFrontPageSlug = () => {
return (window as any).woonoowCustomer?.frontPageSlug || null;
}; };
// Router wrapper component that uses hooks requiring Router context // Router wrapper component that uses hooks requiring Router context
function AppRoutes() { function AppRoutes() {
const initialRoute = getInitialRoute(); const initialRoute = getInitialRoute();
const frontPageSlug = getFrontPageSlug();
return ( return (
<BaseLayout> <BaseLayout>
<Routes> <Routes>
{/* Root route redirects to initial route based on SPA mode */} {/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
<Route path="/" element={<Navigate to={initialRoute} replace />} /> <Route
path="/"
element={
frontPageSlug ? (
<DynamicPageRenderer slug={frontPageSlug} />
) : (
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
)
}
/>
{/* Shop Routes */} {/* Shop Routes */}
<Route path="/shop" element={<Shop />} /> <Route path="/shop" element={<Shop />} />
@@ -128,6 +148,24 @@ function App() {
const appearanceSettings = getAppearanceSettings(); const appearanceSettings = getAppearanceSettings();
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any; const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
// Inject gradient CSS variables
React.useEffect(() => {
// appearanceSettings is already the 'data' object from Assets.php injection
// Structure: { general: { colors: { primary, secondary, accent, text, background, gradientStart, gradientEnd } } }
const colors = appearanceSettings?.general?.colors;
if (colors) {
const root = document.documentElement;
// Inject all color settings as CSS variables
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
if (colors.text) root.style.setProperty('--wn-text', colors.text);
if (colors.background) root.style.setProperty('--wn-background', colors.background);
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
}
}, [appearanceSettings]);
return ( return (
<HelmetProvider> <HelmetProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react';
interface SharedContentProps {
// Content
title?: string;
text?: string; // HTML content
// Image
image?: string;
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
// Layout
containerWidth?: 'full' | 'contained';
// Styles
className?: string;
titleStyle?: React.CSSProperties;
titleClassName?: string;
textStyle?: React.CSSProperties;
textClassName?: string;
headingStyle?: React.CSSProperties; // For prose headings override
imageStyle?: React.CSSProperties;
// Pro Features (for future)
buttons?: Array<{ text: string, url: string }>;
buttonStyle?: { classNames?: string; style?: React.CSSProperties };
}
export const SharedContentLayout: React.FC<SharedContentProps> = ({
title,
text,
image,
imagePosition = 'left',
containerWidth = 'contained',
className,
titleStyle,
titleClassName,
textStyle,
textClassName,
headingStyle,
buttons,
imageStyle,
buttonStyle
}) => {
const hasImage = !!image;
const isImageLeft = imagePosition === 'left';
const isImageRight = imagePosition === 'right';
const isImageTop = imagePosition === 'top';
const isImageBottom = imagePosition === 'bottom';
// Wrapper classes
const containerClasses = cn(
'w-full mx-auto px-4 sm:px-6 lg:px-8',
containerWidth === 'contained' ? 'max-w-7xl' : ''
);
const gridClasses = cn(
'mx-auto',
hasImage && (isImageLeft || isImageRight) ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' : 'max-w-4xl'
);
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
const proseStyle = {
...textStyle,
'--tw-prose-headings': headingStyle?.color,
'--tw-prose-body': textStyle?.color,
} as React.CSSProperties;
return (
<div className={containerClasses}>
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl prose-h1:font-bold prose-h1:mt-4 prose-h1:mb-2',
'prose-h2:text-2xl prose-h2:font-bold prose-h2:mt-3 prose-h2:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -68,7 +68,7 @@ export function SearchableSelect({
type="button" type="button"
role="combobox" role="combobox"
className={cn( className={cn(
"w-full flex items-center justify-between border !rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:border-gray-400", "w-full flex items-center justify-between border rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary/20",
disabled && "opacity-50 cursor-not-allowed", disabled && "opacity-50 cursor-not-allowed",
className className
)} )}

View File

@@ -37,6 +37,16 @@ interface AppearanceSettings {
thankyou: any; thankyou: any;
account: any; account: any;
}; };
menus: {
primary: Array<{
id: string;
label: string;
type: 'page' | 'custom';
value: string;
target: '_self' | '_blank';
}>;
mobile: Array<any>;
};
} }
export function useAppearanceSettings() { export function useAppearanceSettings() {
@@ -293,4 +303,15 @@ export function useFooterSettings() {
}, },
isLoading, isLoading,
}; };
};
export function useMenuSettings() {
const { data, isLoading } = useAppearanceSettings();
return {
primary: data?.menus?.primary ?? [],
mobile: data?.menus?.mobile ?? [],
isLoading,
};
} }

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react'; import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react';
import { useLayout } from '../contexts/ThemeContext'; import { useLayout } from '../contexts/ThemeContext';
import { useCartStore } from '../lib/cart/store'; import { useCartStore } from '../lib/cart/store';
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings'; import { useHeaderSettings, useFooterSettings, useMenuSettings } from '../hooks/useAppearanceSettings';
import { SearchModal } from '../components/SearchModal'; import { SearchModal } from '../components/SearchModal';
import { NewsletterForm } from '../components/NewsletterForm'; import { NewsletterForm } from '../components/NewsletterForm';
import { LayoutWrapper } from './LayoutWrapper'; import { LayoutWrapper } from './LayoutWrapper';
@@ -51,6 +51,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
const { isEnabled } = useModules(); const { isEnabled } = useModules();
const { settings: wishlistSettings } = useModuleSettings('wishlist'); const { settings: wishlistSettings } = useModuleSettings('wishlist');
const footerSettings = useFooterSettings(); const footerSettings = useFooterSettings();
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
@@ -74,7 +75,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
{/* Logo */} {/* Logo */}
{headerSettings.elements.logo && ( {headerSettings.elements.logo && (
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}> <div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
<Link to="/shop" className="flex items-center gap-3 group"> <Link to="/" className="flex items-center gap-3 group">
{storeLogo ? ( {storeLogo ? (
<img <img
src={storeLogo} src={storeLogo}
@@ -103,9 +104,24 @@ function ClassicLayout({ children }: BaseLayoutProps) {
{/* Navigation */} {/* Navigation */}
{headerSettings.elements.navigation && ( {headerSettings.elements.navigation && (
<nav className="hidden md:flex items-center space-x-8"> <nav className="hidden md:flex items-center space-x-8">
{primaryMenu.length > 0 ? (
primaryMenu.map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
)
))
) : (
<>
{(window as any).woonoowCustomer?.frontPageSlug && (
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
)}
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link> <Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a> <a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a> <a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
</>
)}
</nav> </nav>
)} )}
@@ -177,9 +193,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
<div className="md:hidden border-t py-4"> <div className="md:hidden border-t py-4">
{headerSettings.elements.navigation && ( {headerSettings.elements.navigation && (
<nav className="flex flex-col space-y-2 mb-4"> <nav className="flex flex-col space-y-2 mb-4">
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link> {(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a> item.type === 'page' ? (
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a> <Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
)
))}
</nav> </nav>
)} )}
</div> </div>
@@ -198,9 +218,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
</div> </div>
{headerSettings.elements.navigation && ( {headerSettings.elements.navigation && (
<nav className="flex flex-col p-4"> <nav className="flex flex-col p-4">
<Link to="/shop" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Shop</Link> {(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
<a href="/about" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">About</a> item.type === 'page' ? (
<a href="/contact" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Contact</a> <Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</a>
)
))}
</nav> </nav>
)} )}
</div> </div>
@@ -367,6 +391,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
const headerSettings = useHeaderSettings(); const headerSettings = useHeaderSettings();
const { isEnabled } = useModules(); const { isEnabled } = useModules();
const { settings: wishlistSettings } = useModuleSettings('wishlist'); const { settings: wishlistSettings } = useModuleSettings('wishlist');
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
@@ -381,7 +406,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
<div className={`flex flex-col items-center ${paddingClass}`}> <div className={`flex flex-col items-center ${paddingClass}`}>
{/* Logo - Centered */} {/* Logo - Centered */}
{headerSettings.elements.logo && ( {headerSettings.elements.logo && (
<Link to="/shop" className="mb-4"> <Link to="/" className="mb-4">
{storeLogo ? ( {storeLogo ? (
<img <img
src={storeLogo} src={storeLogo}
@@ -404,11 +429,26 @@ function ModernLayout({ children }: BaseLayoutProps) {
<nav className="hidden md:flex items-center space-x-8"> <nav className="hidden md:flex items-center space-x-8">
{headerSettings.elements.navigation && ( {headerSettings.elements.navigation && (
<> <>
{primaryMenu.length > 0 ? (
primaryMenu.map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
)
))
) : (
<>
{(window as any).woonoowCustomer?.frontPageSlug && (
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
)}
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link> <Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a> <a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a> <a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
</> </>
)} )}
</>
)}
{headerSettings.elements.search && ( {headerSettings.elements.search && (
<button <button
onClick={() => setSearchOpen(true)} onClick={() => setSearchOpen(true)}
@@ -456,9 +496,13 @@ function ModernLayout({ children }: BaseLayoutProps) {
<div className="md:hidden border-t py-4"> <div className="md:hidden border-t py-4">
{headerSettings.elements.navigation && ( {headerSettings.elements.navigation && (
<nav className="flex flex-col space-y-2 mb-4"> <nav className="flex flex-col space-y-2 mb-4">
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link> {(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a> item.type === 'page' ? (
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a> <Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
)
))}
</nav> </nav>
)} )}
</div> </div>
@@ -503,6 +547,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
const headerSettings = useHeaderSettings(); const headerSettings = useHeaderSettings();
const { isEnabled } = useModules(); const { isEnabled } = useModules();
const { settings: wishlistSettings } = useModuleSettings('wishlist'); const { settings: wishlistSettings } = useModuleSettings('wishlist');
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
@@ -520,7 +565,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
{headerSettings.elements.logo && ( {headerSettings.elements.logo && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Link to="/shop"> <Link to="/">
{storeLogo ? ( {storeLogo ? (
<img <img
src={storeLogo} src={storeLogo}
@@ -543,7 +588,24 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
{(headerSettings.elements.navigation || hasActions) && ( {(headerSettings.elements.navigation || hasActions) && (
<nav className="hidden md:flex items-center space-x-8"> <nav className="hidden md:flex items-center space-x-8">
{headerSettings.elements.navigation && ( {headerSettings.elements.navigation && (
<>
{primaryMenu.length > 0 ? (
primaryMenu.map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
)
))
) : (
<>
{(window as any).woonoowCustomer?.frontPageSlug && (
<Link to="/" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
)}
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link> <Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
</>
)}
</>
)} )}
{headerSettings.elements.search && ( {headerSettings.elements.search && (
<button <button
@@ -591,7 +653,13 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
<div className="md:hidden border-t py-4"> <div className="md:hidden border-t py-4">
{headerSettings.elements.navigation && ( {headerSettings.elements.navigation && (
<nav className="flex flex-col space-y-2 mb-4"> <nav className="flex flex-col space-y-2 mb-4">
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link> {(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
)
))}
</nav> </nav>
)} )}
</div> </div>
@@ -656,7 +724,7 @@ function LaunchLayout({ children }: BaseLayoutProps) {
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className={`flex items-center justify-center ${heightClass}`}> <div className={`flex items-center justify-center ${heightClass}`}>
{headerSettings.elements.logo && ( {headerSettings.elements.logo && (
<Link to="/shop"> <Link to="/">
{storeLogo ? ( {storeLogo ? (
<img <img
src={storeLogo} src={storeLogo}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
interface ImageTextSectionProps { interface ImageTextSectionProps {
id: string; id: string;
@@ -7,6 +8,7 @@ interface ImageTextSectionProps {
title?: string; title?: string;
text?: string; text?: string;
image?: string; image?: string;
elementStyles?: Record<string, any>;
} }
export function ImageTextSection({ export function ImageTextSection({
@@ -16,60 +18,87 @@ export function ImageTextSection({
title, title,
text, text,
image, image,
}: ImageTextSectionProps) { cta_text,
cta_url,
elementStyles,
styles,
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
const isImageLeft = layout === 'image-left' || layout === 'left'; const isImageLeft = layout === 'image-left' || layout === 'left';
const isImageRight = layout === 'image-right' || layout === 'right'; const isImageRight = layout === 'image-right' || layout === 'right';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const buttonStyle = getTextStyles('button');
const imageStyle = elementStyles?.['image'] || {};
// Height preset support
const heightPreset = styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-24',
'small': 'py-8 md:py-16',
'medium': 'py-16 md:py-32',
'large': 'py-24 md:py-48',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
return ( return (
<section <section
id={id} id={id}
className={cn( className={cn(
'wn-section wn-image-text', 'wn-section wn-image-text',
`wn-image-text--${layout}`,
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
'py-16 md:py-24', heightClasses,
{ {
'bg-white': colorScheme === 'default',
'bg-muted': colorScheme === 'muted', 'bg-muted': colorScheme === 'muted',
'bg-primary/5': colorScheme === 'primary', 'bg-primary/5': colorScheme === 'primary',
} }
)} )}
> >
<div className="container mx-auto px-4"> <SharedContentLayout
<div className={cn( title={title}
'flex flex-col md:flex-row items-center gap-8 md:gap-16', text={text}
{ image={image}
'md:flex-row-reverse': isImageRight, imagePosition={isImageRight ? 'right' : 'left'}
} containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
)}> titleStyle={titleStyle.style}
{/* Image */} titleClassName={titleStyle.classNames}
{image && ( textStyle={textStyle.style}
<div className="w-full md:w-1/2"> textClassName={textStyle.classNames}
<img imageStyle={{
src={image} backgroundColor: imageStyle.backgroundColor,
alt={title || 'Section image'} objectFit: imageStyle.objectFit,
className="w-full h-auto rounded-xl shadow-lg" }}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
classNames: buttonStyle.classNames,
style: buttonStyle.style
}}
/> />
</div>
)}
{/* Content */}
<div className="w-full md:w-1/2">
{title && (
<h2 className="text-3xl md:text-4xl font-bold mb-6">
{title}
</h2>
)}
{text && (
<div
className="prose prose-lg text-gray-600"
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
</div>
</div>
</div>
</section> </section>
); );
} }

View File

@@ -150,7 +150,7 @@ export default function Shop() {
placeholder="Search products..." placeholder="Search products..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" className="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/> />
{search && ( {search && (
<button <button

View File

@@ -43,6 +43,13 @@ class AppearanceController {
'permission_callback' => [__CLASS__, 'check_permission'], 'permission_callback' => [__CLASS__, 'check_permission'],
]); ]);
// Save menu settings
register_rest_route(self::API_NAMESPACE, '/appearance/menus', [
'methods' => 'POST',
'callback' => [__CLASS__, 'save_menus'],
'permission_callback' => [__CLASS__, 'check_permission'],
]);
// Save page-specific settings // Save page-specific settings
register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [ register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [
'methods' => 'POST', 'methods' => 'POST',
@@ -73,7 +80,11 @@ class AppearanceController {
* Get all appearance settings * Get all appearance settings
*/ */
public static function get_settings(WP_REST_Request $request) { public static function get_settings(WP_REST_Request $request) {
$settings = get_option(self::OPTION_KEY, self::get_default_settings()); $stored = get_option(self::OPTION_KEY, []);
$defaults = self::get_default_settings();
// Merge stored with defaults to ensure all fields exist (recursive)
$settings = array_replace_recursive($defaults, $stored);
return new WP_REST_Response([ return new WP_REST_Response([
'success' => true, 'success' => true,
@@ -85,7 +96,11 @@ class AppearanceController {
* Save general settings * Save general settings
*/ */
public static function save_general(WP_REST_Request $request) { public static function save_general(WP_REST_Request $request) {
$settings = get_option(self::OPTION_KEY, self::get_default_settings()); $settings = get_option(self::OPTION_KEY, []);
$defaults = self::get_default_settings();
$settings = array_replace_recursive($defaults, $settings);
$colors = $request->get_param('colors') ?? [];
$general_data = [ $general_data = [
'spa_mode' => sanitize_text_field($request->get_param('spaMode')), 'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
@@ -101,11 +116,13 @@ class AppearanceController {
'scale' => floatval($request->get_param('typography')['scale'] ?? 1.0), 'scale' => floatval($request->get_param('typography')['scale'] ?? 1.0),
], ],
'colors' => [ 'colors' => [
'primary' => sanitize_hex_color($request->get_param('colors')['primary'] ?? '#1a1a1a'), 'primary' => sanitize_hex_color($colors['primary'] ?? '#1a1a1a'),
'secondary' => sanitize_hex_color($request->get_param('colors')['secondary'] ?? '#6b7280'), 'secondary' => sanitize_hex_color($colors['secondary'] ?? '#6b7280'),
'accent' => sanitize_hex_color($request->get_param('colors')['accent'] ?? '#3b82f6'), 'accent' => sanitize_hex_color($colors['accent'] ?? '#3b82f6'),
'text' => sanitize_hex_color($request->get_param('colors')['text'] ?? '#111827'), 'text' => sanitize_hex_color($colors['text'] ?? '#111827'),
'background' => sanitize_hex_color($request->get_param('colors')['background'] ?? '#ffffff'), 'background' => sanitize_hex_color($colors['background'] ?? '#ffffff'),
'gradientStart' => sanitize_hex_color($colors['gradientStart'] ?? '#9333ea'),
'gradientEnd' => sanitize_hex_color($colors['gradientEnd'] ?? '#3b82f6'),
], ],
]; ];
@@ -231,6 +248,44 @@ class AppearanceController {
], 200); ], 200);
} }
/**
* Save menu settings
*/
public static function save_menus(WP_REST_Request $request) {
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$menus = $request->get_param('menus') ?? [];
// Sanitize menus
$sanitized_menus = [
'primary' => [],
'mobile' => [], // Optional separate mobile menu
];
foreach (['primary', 'mobile'] as $location) {
if (isset($menus[$location]) && is_array($menus[$location])) {
foreach ($menus[$location] as $item) {
$sanitized_menus[$location][] = [
'id' => sanitize_text_field($item['id'] ?? uniqid()),
'label' => sanitize_text_field($item['label'] ?? ''),
'type' => sanitize_text_field($item['type'] ?? 'page'), // page, custom
'value' => sanitize_text_field($item['value'] ?? ''), // slug or url
'target' => sanitize_text_field($item['target'] ?? '_self'),
];
}
}
}
$settings['menus'] = $sanitized_menus;
update_option(self::OPTION_KEY, $settings);
return new WP_REST_Response([
'success' => true,
'message' => 'Menu settings saved successfully',
'data' => $sanitized_menus,
], 200);
}
/** /**
* Save page-specific settings * Save page-specific settings
*/ */
@@ -389,11 +444,23 @@ class AppearanceController {
'sort_order' => 'ASC', 'sort_order' => 'ASC',
]); ]);
$pages_list = array_map(function($page) { $store_pages = [
(int) get_option('woocommerce_shop_page_id'),
(int) get_option('woocommerce_cart_page_id'),
(int) get_option('woocommerce_checkout_page_id'),
(int) get_option('woocommerce_myaccount_page_id'),
];
$pages_list = array_map(function($page) use ($store_pages) {
$is_woonoow = !empty(get_post_meta($page->ID, '_wn_page_structure', true));
$is_store = in_array((int)$page->ID, $store_pages, true);
return [ return [
'id' => $page->ID, 'id' => $page->ID,
'title' => $page->post_title, 'title' => $page->post_title,
'slug' => $page->post_name, 'slug' => $page->post_name,
'is_woonoow_page' => $is_woonoow,
'is_store_page' => $is_store,
]; ];
}, $pages); }, $pages);
@@ -427,6 +494,8 @@ class AppearanceController {
'accent' => '#3b82f6', 'accent' => '#3b82f6',
'text' => '#111827', 'text' => '#111827',
'background' => '#ffffff', 'background' => '#ffffff',
'gradientStart' => '#9333ea',
'gradientEnd' => '#3b82f6',
], ],
], ],
'header' => [ 'header' => [
@@ -458,6 +527,14 @@ class AppearanceController {
], ],
'social_links' => [], 'social_links' => [],
], ],
'menus' => [
'primary' => [
['id' => 'home', 'label' => 'Home', 'type' => 'page', 'value' => '/', 'target' => '_self'],
['id' => 'shop', 'label' => 'Shop', 'type' => 'page', 'value' => 'shop', 'target' => '_self'],
],
// Fallback for mobile if empty is to use primary
'mobile' => [],
],
'pages' => [ 'pages' => [
'shop' => [ 'shop' => [
'layout' => [ 'layout' => [

View File

@@ -8,6 +8,9 @@ class Menu {
add_action('admin_head', [__CLASS__, 'localize_wc_menus'], 999); add_action('admin_head', [__CLASS__, 'localize_wc_menus'], 999);
// Add link to standalone admin in admin bar // Add link to standalone admin in admin bar
add_action('admin_bar_menu', [__CLASS__, 'add_admin_bar_link'], 100); add_action('admin_bar_menu', [__CLASS__, 'add_admin_bar_link'], 100);
// Add custom state for SPA Front Page
add_filter('display_post_states', [__CLASS__, 'add_spa_page_state'], 10, 2);
} }
public static function register() { public static function register() {
add_menu_page( add_menu_page(
@@ -133,4 +136,23 @@ class Menu {
} }
} }
/**
* Add "WooNooW SPA Page" state to the pages list
*
* @param array $states Array of post states.
* @param \WP_Post $post Current post object.
* @return array Modified post states.
*/
public static function add_spa_page_state($states, $post) {
$settings = get_option('woonoow_appearance_settings', []);
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
if ((int)$post->ID === (int)$spa_frontpage_id) {
$states['spa_frontpage'] = __('WooNooW Front Page', 'woonoow');
} elseif (!empty(get_post_meta($post->ID, '_wn_page_structure', true))) {
$states['woonoow_page'] = __('WooNooW Page', 'woonoow');
}
return $states;
}
} }

View File

@@ -226,6 +226,26 @@ class NotificationsController {
'permission_callback' => [$this, 'check_permission'], 'permission_callback' => [$this, 'check_permission'],
], ],
]); ]);
// POST /woonoow/v1/notifications/templates/:eventId/:channelId/send-test
register_rest_route($this->namespace, '/' . $this->rest_base . '/templates/(?P<eventId>[a-zA-Z0-9_-]+)/(?P<channelId>[a-zA-Z0-9_-]+)/send-test', [
[
'methods' => 'POST',
'callback' => [$this, 'send_test_email'],
'permission_callback' => [$this, 'check_permission'],
'args' => [
'email' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_email',
],
'recipient' => [
'default' => 'customer',
'type' => 'string',
],
],
],
]);
} }
/** /**
@@ -931,4 +951,411 @@ class NotificationsController {
'per_page' => $per_page, 'per_page' => $per_page,
], 200); ], 200);
} }
/**
* Send test email for a notification template
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error
*/
public function send_test_email(WP_REST_Request $request) {
$event_id = $request->get_param('eventId');
$channel_id = $request->get_param('channelId');
$recipient_type = $request->get_param('recipient') ?? 'customer';
$to_email = $request->get_param('email');
// Validate email
if (!is_email($to_email)) {
return new \WP_Error(
'invalid_email',
__('Invalid email address', 'woonoow'),
['status' => 400]
);
}
// Only support email channel for test
if ($channel_id !== 'email') {
return new \WP_Error(
'unsupported_channel',
__('Test sending is only available for email channel', 'woonoow'),
['status' => 400]
);
}
// Get template
$template = TemplateProvider::get_template($event_id, $channel_id, $recipient_type);
if (!$template) {
return new \WP_Error(
'template_not_found',
__('Template not found', 'woonoow'),
['status' => 404]
);
}
// Build sample data for variables
$sample_data = $this->get_sample_data_for_event($event_id);
// Replace variables in subject and body
$subject = '[TEST] ' . $this->replace_variables($template['subject'] ?? '', $sample_data);
$body_markdown = $this->replace_variables($template['body'] ?? '', $sample_data);
// Render email using EmailRenderer
$email_renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
// We need to manually render since we're not triggering a real event
$html = $this->render_test_email($body_markdown, $subject, $sample_data);
// Set content type to HTML
$headers = ['Content-Type: text/html; charset=UTF-8'];
// Send email
$sent = wp_mail($to_email, $subject, $html, $headers);
if (!$sent) {
return new \WP_Error(
'send_failed',
__('Failed to send test email. Check your mail server configuration.', 'woonoow'),
['status' => 500]
);
}
return new WP_REST_Response([
'success' => true,
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $to_email),
], 200);
}
/**
* Get sample data for an event type
*
* @param string $event_id
* @return array
*/
private function get_sample_data_for_event($event_id) {
$base_data = [
'site_name' => get_bloginfo('name'),
'store_name' => get_bloginfo('name'),
'store_url' => home_url(),
'shop_url' => get_permalink(wc_get_page_id('shop')),
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
'support_email' => get_option('admin_email'),
'current_year' => date('Y'),
'customer_name' => 'John Doe',
'customer_first_name' => 'John',
'customer_last_name' => 'Doe',
'customer_email' => 'john@example.com',
'customer_phone' => '+1 234 567 8900',
'login_url' => wp_login_url(),
];
// Order-related events
if (strpos($event_id, 'order') !== false) {
$base_data = array_merge($base_data, [
'order_number' => '12345',
'order_id' => '12345',
'order_date' => date('F j, Y'),
'order_total' => wc_price(129.99),
'order_subtotal' => wc_price(109.99),
'order_tax' => wc_price(10.00),
'order_shipping' => wc_price(10.00),
'order_discount' => wc_price(0),
'order_status' => 'Processing',
'order_url' => '#',
'payment_method' => 'Credit Card',
'payment_status' => 'Paid',
'payment_date' => date('F j, Y'),
'transaction_id' => 'TXN123456789',
'shipping_method' => 'Standard Shipping',
'estimated_delivery' => date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days')),
'completion_date' => date('F j, Y'),
'billing_address' => '123 Main St, City, State 12345, Country',
'shipping_address' => '123 Main St, City, State 12345, Country',
'tracking_number' => 'TRACK123456',
'tracking_url' => '#',
'shipping_carrier' => 'Standard Carrier',
'payment_retry_url' => '#',
'review_url' => '#',
'order_items' => $this->get_sample_order_items_html(),
'order_items_table' => $this->get_sample_order_items_html(),
]);
}
// Customer account events
if (strpos($event_id, 'customer') !== false || strpos($event_id, 'account') !== false) {
$base_data = array_merge($base_data, [
'customer_username' => 'johndoe',
'user_temp_password' => 'SamplePass123',
'reset_link' => '#',
'reset_key' => 'abc123xyz',
'user_login' => 'johndoe',
'user_email' => 'john@example.com',
]);
}
return $base_data;
}
/**
* Get sample order items HTML
*
* @return string
*/
private function get_sample_order_items_html() {
return '<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<thead>
<tr style="background: #f5f5f5;">
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #ddd;">Product</th>
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #ddd;">Qty</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;">
<strong>Sample Product</strong><br>
<span style="color: #666; font-size: 13px;">Size: M, Color: Blue</span>
</td>
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">2</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">' . wc_price(59.98) . '</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;">
<strong>Another Product</strong><br>
<span style="color: #666; font-size: 13px;">Option: Standard</span>
</td>
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">1</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">' . wc_price(50.01) . '</td>
</tr>
</tbody>
</table>';
}
/**
* Replace variables in text
*
* @param string $text
* @param array $variables
* @return string
*/
private function replace_variables($text, $variables) {
foreach ($variables as $key => $value) {
$text = str_replace('{' . $key . '}', $value, $text);
}
return $text;
}
/**
* Render test email HTML
*
* @param string $body_markdown
* @param string $subject
* @param array $variables
* @return string
*/
private function render_test_email($body_markdown, $subject, $variables) {
// Parse cards
$content = $this->parse_cards_for_test($body_markdown);
// Get appearance settings for colors
$appearance = get_option('woonoow_appearance_settings', []);
$colors = $appearance['general']['colors'] ?? [];
$primary_color = $colors['primary'] ?? '#7f54b3';
$secondary_color = $colors['secondary'] ?? '#7f54b3';
$hero_gradient_start = $colors['gradientStart'] ?? '#667eea';
$hero_gradient_end = $colors['gradientEnd'] ?? '#764ba2';
// Get email settings for branding
$email_settings = get_option('woonoow_email_settings', []);
$logo_url = $email_settings['logo_url'] ?? '';
$header_text = $email_settings['header_text'] ?? $variables['store_name'];
$footer_text = $email_settings['footer_text'] ?? sprintf('© %s %s. All rights reserved.', date('Y'), $variables['store_name']);
$footer_text = str_replace('{current_year}', date('Y'), $footer_text);
// Build header
if (!empty($logo_url)) {
$header = sprintf(
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
esc_url($variables['store_url']),
esc_url($logo_url),
esc_attr($variables['store_name'])
);
} else {
$header = sprintf(
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
esc_url($variables['store_url']),
esc_html($header_text)
);
}
// Build full HTML
$html = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>' . esc_html($subject) . '</title>
<style>
body { font-family: "Inter", Arial, sans-serif; background: #f8f8f8; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; }
.header { padding: 32px; text-align: center; background: #f8f8f8; }
.card-gutter { padding: 0 16px; }
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; width: 100%; box-sizing: border-box; }
.card-hero { background: linear-gradient(135deg, ' . esc_attr($hero_gradient_start) . ' 0%, ' . esc_attr($hero_gradient_end) . ' 100%); color: #ffffff; }
.card-hero * { color: #ffffff !important; }
.card-success { background-color: #f0fdf4; }
.card-info { background-color: #f0f7ff; }
.card-warning { background-color: #fff8e1; }
.card-basic { background: none; padding: 0; }
h1, h2, h3 { margin-top: 0; color: #333; }
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
.button { display: inline-block; background: ' . esc_attr($primary_color) . '; color: #ffffff !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
.button-outline { display: inline-block; background: transparent; color: ' . esc_attr($secondary_color) . ' !important; padding: 12px 26px; border: 2px solid ' . esc_attr($secondary_color) . '; border-radius: 6px; text-decoration: none; font-weight: 600; }
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
</style>
</head>
<body>
<div class="container">
<div class="header">' . $header . '</div>
<div class="card-gutter">' . $content . '</div>
<div class="footer"><p>' . nl2br(esc_html($footer_text)) . '</p></div>
</div>
</body>
</html>';
return $html;
}
/**
* Parse cards for test email
*
* @param string $content
* @return string
*/
private function parse_cards_for_test($content) {
// Parse [card:type] syntax
$content = preg_replace_callback(
'/\[card:(\w+)\](.*?)\[\/card\]/s',
function($matches) {
$type = $matches[1];
$card_content = $this->markdown_to_html($matches[2]);
return '<div class="card card-' . esc_attr($type) . '">' . $card_content . '</div>';
},
$content
);
// Parse [card type="..."] syntax
$content = preg_replace_callback(
'/\[card([^\]]*)\](.*?)\[\/card\]/s',
function($matches) {
$attrs = $matches[1];
$card_content = $this->markdown_to_html($matches[2]);
$type = 'default';
if (preg_match('/type=["\']([^"\']+)["\']/', $attrs, $type_match)) {
$type = $type_match[1];
}
return '<div class="card card-' . esc_attr($type) . '">' . $card_content . '</div>';
},
$content
);
// Parse buttons - new [button:style](url)Text[/button] syntax
$content = preg_replace_callback(
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
function($matches) {
$style = $matches[1];
$url = $matches[2];
$text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
},
$content
);
// Parse buttons - old [button url="..." style="..."]Text[/button] syntax
$content = preg_replace_callback(
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](\w+)["\'])?\]([^\[]+)\[\/button\]/',
function($matches) {
$url = $matches[1];
$style = $matches[2] ?? 'solid';
$text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
},
$content
);
// If no cards found, wrap in default card
if (strpos($content, '<div class="card') === false) {
$content = '<div class="card">' . $this->markdown_to_html($content) . '</div>';
}
return $content;
}
/**
* Basic markdown to HTML conversion
*
* @param string $text
* @return string
*/
private function markdown_to_html($text) {
// Parse buttons FIRST - new [button:style](url)Text[/button] syntax
$text = preg_replace_callback(
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
function($matches) {
$style = $matches[1];
$url = $matches[2];
$btn_text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
},
$text
);
// Parse buttons - old [button url="..."] syntax
$text = preg_replace_callback(
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=[\'"](\\w+)[\'"])?\]([^\[]+)\[\/button\]/',
function($matches) {
$url = $matches[1];
$style = $matches[2] ?? 'solid';
$btn_text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
},
$text
);
// Headers
$text = preg_replace('/^### (.+)$/m', '<h3>$1</h3>', $text);
$text = preg_replace('/^## (.+)$/m', '<h2>$1</h2>', $text);
$text = preg_replace('/^# (.+)$/m', '<h1>$1</h1>', $text);
// Bold
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
// Italic
$text = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $text);
// Links (but not button syntax - already handled above)
$text = preg_replace('/\[(?!button)([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $text);
// List items
$text = preg_replace('/^- (.+)$/m', '<li>$1</li>', $text);
$text = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $text);
// Paragraphs - wrap lines that aren't already wrapped
$lines = explode("\n", $text);
$result = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
if (!preg_match('/^<(h[1-6]|ul|li|div|p|table|tr|td|th)/', $line)) {
$line = '<p>' . $line . '</p>';
}
$result[] = $line;
}
return implode("\n", $result);
}
} }

View File

@@ -5,7 +5,9 @@ use WP_REST_Request;
use WP_REST_Response; use WP_REST_Response;
use WP_Error; use WP_Error;
use WooNooW\Frontend\PlaceholderRenderer; use WooNooW\Frontend\PlaceholderRenderer;
use WooNooW\Frontend\PageSSR; use WooNooW\Frontend\PageSSR;
use WooNooW\Templates\TemplateRegistry;
/** /**
* Pages Controller * Pages Controller
@@ -20,6 +22,13 @@ class PagesController
{ {
$namespace = 'woonoow/v1'; $namespace = 'woonoow/v1';
// Unset SPA Landing (Must be before generic slug route)
register_rest_route($namespace, '/pages/unset-spa-landing', [
'methods' => 'POST',
'callback' => [__CLASS__, 'unset_spa_landing'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// List all pages and templates // List all pages and templates
register_rest_route($namespace, '/pages', [ register_rest_route($namespace, '/pages', [
'methods' => 'GET', 'methods' => 'GET',
@@ -41,6 +50,13 @@ class PagesController
], ],
]); ]);
// Get template presets (Must be before generic template cpt route)
register_rest_route($namespace, '/templates/presets', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_template_presets'],
'permission_callback' => '__return_true',
]);
// Get/Save CPT templates // Get/Save CPT templates
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [ register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
[ [
@@ -62,6 +78,8 @@ class PagesController
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
]); ]);
// Create new page // Create new page
register_rest_route($namespace, '/pages', [ register_rest_route($namespace, '/pages', [
'methods' => 'POST', 'methods' => 'POST',
@@ -82,6 +100,22 @@ class PagesController
'callback' => [__CLASS__, 'render_template_preview'], 'callback' => [__CLASS__, 'render_template_preview'],
'permission_callback' => [__CLASS__, 'check_admin_permission'], 'permission_callback' => [__CLASS__, 'check_admin_permission'],
]); ]);
// Set page as SPA Landing (shown at SPA root route)
register_rest_route($namespace, '/pages/(?P<id>\d+)/set-as-spa-landing', [
'methods' => 'POST',
'callback' => [__CLASS__, 'set_as_spa_landing'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Delete page
register_rest_route($namespace, '/pages/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_page'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
} }
/** /**
@@ -93,12 +127,24 @@ class PagesController
} }
/** /**
* Get all pages and templates * Get available template presets
*/ */
public static function get_pages(WP_REST_Request $request) public static function get_template_presets()
{
return new WP_REST_Response(TemplateRegistry::get_templates(), 200);
}
/**
* Get all editable pages (and templates)
*/
public static function get_pages()
{ {
$result = []; $result = [];
// Get SPA settings
$settings = get_option('woonoow_appearance_settings', []);
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
// Get structural pages (pages with WooNooW structure) // Get structural pages (pages with WooNooW structure)
$pages = get_posts([ $pages = get_posts([
'post_type' => 'page', 'post_type' => 'page',
@@ -119,6 +165,7 @@ class PagesController
'title' => $page->post_title, 'title' => $page->post_title,
'url' => get_permalink($page), 'url' => get_permalink($page),
'icon' => 'page', 'icon' => 'page',
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
]; ];
} }
@@ -155,6 +202,10 @@ class PagesController
$structure = get_post_meta($page->ID, '_wn_page_structure', true); $structure = get_post_meta($page->ID, '_wn_page_structure', true);
// Get SPA settings
$settings = get_option('woonoow_appearance_settings', []);
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
// Get SEO data (Yoast/Rank Math) // Get SEO data (Yoast/Rank Math)
$seo = self::get_seo_data($page->ID); $seo = self::get_seo_data($page->ID);
@@ -163,8 +214,8 @@ class PagesController
'type' => 'page', 'type' => 'page',
'slug' => $page->post_name, 'slug' => $page->post_name,
'title' => $page->post_title, 'title' => $page->post_title,
'url' => get_permalink($page),
'seo' => $seo, 'seo' => $seo,
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
'structure' => $structure ?: ['sections' => []], 'structure' => $structure ?: ['sections' => []],
], 200); ], 200);
} }
@@ -198,6 +249,9 @@ class PagesController
update_post_meta($page->ID, '_wn_page_structure', $save_data); update_post_meta($page->ID, '_wn_page_structure', $save_data);
// Invalidate SSR cache
delete_transient("wn_ssr_page_{$page->ID}");
return new WP_REST_Response([ return new WP_REST_Response([
'success' => true, 'success' => true,
'page' => [ 'page' => [
@@ -327,6 +381,53 @@ class PagesController
], 200); ], 200);
} }
/**
* Set page as SPA Landing (the page shown at SPA root route)
* This does NOT affect WordPress page_on_front setting.
*/
public static function set_as_spa_landing(WP_REST_Request $request) {
$id = (int)$request->get_param('id');
// Verify the page exists
$page = get_post($id);
if (!$page || $page->post_type !== 'page') {
return new WP_Error('invalid_page', 'Page not found', ['status' => 404]);
}
// Update WooNooW SPA settings - set this page as the SPA frontpage
$settings = get_option('woonoow_appearance_settings', []);
if (!isset($settings['general'])) {
$settings['general'] = [];
}
$settings['general']['spa_frontpage'] = $id;
update_option('woonoow_appearance_settings', $settings);
return new WP_REST_Response([
'success' => true,
'id' => $id,
'message' => 'SPA Landing page set successfully'
], 200);
}
/**
* Unset SPA Landing (the page shown at SPA root route)
* After unsetting, SPA will redirect to /shop or /checkout based on mode
*/
public static function unset_spa_landing(WP_REST_Request $request) {
// Update WooNooW SPA settings - clear the SPA frontpage
$settings = get_option('woonoow_appearance_settings', []);
if (isset($settings['general'])) {
$settings['general']['spa_frontpage'] = 0;
}
update_option('woonoow_appearance_settings', $settings);
return new WP_REST_Response([
'success' => true,
'message' => 'SPA Landing page unset. Root will now redirect to shop/checkout.'
], 200);
}
/** /**
* Create new page * Create new page
*/ */
@@ -365,6 +466,15 @@ class PagesController
'created_at' => current_time('mysql'), 'created_at' => current_time('mysql'),
]; ];
// Apply template if provided
$template_id = $body['templateId'] ?? null;
if ($template_id) {
$template = TemplateRegistry::get_template($template_id);
if ($template) {
$structure['sections'] = $template['sections'];
}
}
update_post_meta($page_id, '_wn_page_structure', $structure); update_post_meta($page_id, '_wn_page_structure', $structure);
return new WP_REST_Response([ return new WP_REST_Response([
@@ -378,6 +488,42 @@ class PagesController
], 201); ], 201);
} }
/**
* Delete page
*/
public static function delete_page(WP_REST_Request $request) {
$id = (int)$request->get_param('id');
$page = get_post($id);
if (!$page || $page->post_type !== 'page') {
return new WP_Error('not_found', 'Page not found', ['status' => 404]);
}
// Check if it's the SPA front page
$settings = get_option('woonoow_appearance_settings', []);
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
if ((int)$id === (int)$spa_frontpage_id) {
// Unset SPA frontpage if deleting it
if (isset($settings['general'])) {
$settings['general']['spa_frontpage'] = 0;
update_option('woonoow_appearance_settings', $settings);
}
}
$deleted = wp_delete_post($id, true); // Force delete
if (!$deleted) {
return new WP_Error('delete_failed', 'Failed to delete page', ['status' => 500]);
}
return new WP_REST_Response([
'success' => true,
'id' => $id,
'message' => 'Page deleted successfully'
], 200);
}
// ======================================== // ========================================
// Helper Methods // Helper Methods
// ======================================== // ========================================

View File

@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
*/ */
class NavigationRegistry { class NavigationRegistry {
const NAV_OPTION = 'wnw_nav_tree'; const NAV_OPTION = 'wnw_nav_tree';
const NAV_VERSION = '1.1.0'; // Added Pages (Page Editor) const NAV_VERSION = '1.2.0'; // Added Menus (Menu Editor)
/** /**
* Initialize hooks * Initialize hooks
@@ -170,6 +170,7 @@ class NavigationRegistry {
'children' => [ 'children' => [
['label' => __('General', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/general'], ['label' => __('General', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/general'],
['label' => __('Pages', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/pages'], ['label' => __('Pages', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/pages'],
['label' => __('Menus', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/menus'],
['label' => __('Header', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/header'], ['label' => __('Header', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/header'],
['label' => __('Footer', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/footer'], ['label' => __('Footer', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/footer'],
['label' => __('Shop', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/shop'], ['label' => __('Shop', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/shop'],

View File

@@ -373,13 +373,15 @@ class EmailRenderer {
$content = MarkdownParser::parse($content); $content = MarkdownParser::parse($content);
// Get email customization settings for colors // Get email customization settings for colors
$email_settings = get_option('woonoow_email_settings', []); // Use unified colors from Appearance > General > Colors
$primary_color = $email_settings['primary_color'] ?? '#7f54b3'; $appearance = get_option('woonoow_appearance_settings', []);
$secondary_color = $email_settings['secondary_color'] ?? '#7f54b3'; $colors = $appearance['general']['colors'] ?? [];
$button_text_color = $email_settings['button_text_color'] ?? '#ffffff'; $primary_color = $colors['primary'] ?? '#7f54b3';
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea'; $secondary_color = $colors['secondary'] ?? '#7f54b3';
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2'; $button_text_color = '#ffffff'; // Always white on primary buttons
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff'; $hero_gradient_start = $colors['gradientStart'] ?? '#667eea';
$hero_gradient_end = $colors['gradientEnd'] ?? '#764ba2';
$hero_text_color = '#ffffff'; // Always white on gradient
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility // Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
// Helper function to generate button HTML // Helper function to generate button HTML

View File

@@ -144,11 +144,11 @@ class Assets {
$theme_settings = array_replace_recursive($default_settings, $spa_settings); $theme_settings = array_replace_recursive($default_settings, $spa_settings);
// Get appearance settings and preload them // Get appearance settings and preload them
$appearance_settings = get_option('woonoow_appearance_settings', []); $stored_settings = get_option('woonoow_appearance_settings', []);
if (empty($appearance_settings)) { $default_appearance = \WooNooW\Admin\AppearanceController::get_default_settings();
// Use defaults from AppearanceController
$appearance_settings = \WooNooW\Admin\AppearanceController::get_default_settings(); // Merge stored settings with defaults to ensure new fields (like gradient colors) exist
} $appearance_settings = array_replace_recursive($default_appearance, $stored_settings);
// Get WooCommerce currency settings // Get WooCommerce currency settings
$currency_settings = [ $currency_settings = [
@@ -198,12 +198,23 @@ class Assets {
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0; $spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = $spa_page_id ? get_post($spa_page_id) : null; $spa_page = $spa_page_id ? get_post($spa_page_id) : null;
// Check if SPA page is set as WordPress frontpage // Check if SPA Entry Page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front'); $frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id; $is_spa_wp_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
// If SPA is frontpage, base path is /, otherwise use page slug // Get SPA Landing page (explicit setting, separate from Entry Page)
$base_path = $is_spa_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store'); // This determines what content to show at the SPA root route "/"
$spa_frontpage_id = $appearance_settings['general']['spa_frontpage'] ?? 0;
$front_page_slug = '';
if ($spa_frontpage_id) {
$spa_frontpage = get_post($spa_frontpage_id);
if ($spa_frontpage) {
$front_page_slug = $spa_frontpage->post_name;
}
}
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
// Check if BrowserRouter is enabled (default: true for SEO) // Check if BrowserRouter is enabled (default: true for SEO)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true; $use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
@@ -223,6 +234,8 @@ class Assets {
'appearanceSettings' => $appearance_settings, 'appearanceSettings' => $appearance_settings,
'basePath' => $base_path, 'basePath' => $base_path,
'useBrowserRouter' => $use_browser_router, 'useBrowserRouter' => $use_browser_router,
'frontPageSlug' => $front_page_slug,
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
]; ];
?> ?>
@@ -270,11 +283,11 @@ class Assets {
return true; return true;
} }
// Get Customer SPA settings // Get SPA mode from appearance settings (the correct source)
$spa_settings = get_option('woonoow_customer_spa_settings', []); $appearance_settings = get_option('woonoow_appearance_settings', []);
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled'; $mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// If disabled, don't load // If disabled, only load for pages with shortcodes
if ($mode === 'disabled') { if ($mode === 'disabled') {
// Special handling for WooCommerce Shop page (it's an archive, not a regular post) // Special handling for WooCommerce Shop page (it's an archive, not a regular post)
if (function_exists('is_shop') && is_shop()) { if (function_exists('is_shop') && is_shop()) {

View File

@@ -49,10 +49,13 @@ class PageSSR
// Generate section ID for anchor links // Generate section ID for anchor links
$section_id = $section['id'] ?? 'section-' . uniqid(); $section_id = $section['id'] ?? 'section-' . uniqid();
$element_styles = $section['elementStyles'] ?? [];
$styles = $section['styles'] ?? []; // Section wrapper styles (bg, overlay)
// Render based on section type // Render based on section type
$method = 'render_' . str_replace('-', '_', $type); $method = 'render_' . str_replace('-', '_', $type);
if (method_exists(__CLASS__, $method)) { if (method_exists(__CLASS__, $method)) {
return self::$method($resolved_props, $layout, $color_scheme, $section_id); return self::$method($resolved_props, $layout, $color_scheme, $section_id, $element_styles, $styles);
} }
// Fallback: generic section wrapper // Fallback: generic section wrapper
@@ -95,10 +98,25 @@ class PageSSR
// Section Renderers // Section Renderers
// ======================================== // ========================================
/**
* Helper to generate style attribute string
*/
private static function generate_style_attr($styles) {
if (empty($styles)) return '';
$css = [];
if (!empty($styles['color'])) $css[] = "color: {$styles['color']}";
if (!empty($styles['backgroundColor'])) $css[] = "background-color: {$styles['backgroundColor']}";
if (!empty($styles['fontSize'])) $css[] = "font-size: {$styles['fontSize']}"; // Note: assumes value has unit or is handled by class, but inline style works for specific values
// Add more mapping if needed, or rely on frontend to send valid CSS values
return empty($css) ? '' : 'style="' . implode(';', $css) . '"';
}
/** /**
* Render Hero section * Render Hero section
*/ */
public static function render_hero($props, $layout, $color_scheme, $id) public static function render_hero($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{ {
$title = esc_html($props['title'] ?? ''); $title = esc_html($props['title'] ?? '');
$subtitle = esc_html($props['subtitle'] ?? ''); $subtitle = esc_html($props['subtitle'] ?? '');
@@ -106,21 +124,50 @@ class PageSSR
$cta_text = esc_html($props['cta_text'] ?? ''); $cta_text = esc_html($props['cta_text'] ?? '');
$cta_url = esc_url($props['cta_url'] ?? ''); $cta_url = esc_url($props['cta_url'] ?? '');
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\">"; // Section Styles (Background & Spacing)
$bg_color = $section_styles['backgroundColor'] ?? '';
$bg_image = $section_styles['backgroundImage'] ?? '';
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? '';
if ($image) { $section_css = "";
if ($bg_color) $section_css .= "background-color: {$bg_color};";
if ($bg_image) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
if ($pt) $section_css .= "padding-top: {$pt};";
if ($pb) $section_css .= "padding-bottom: {$pb};";
if ($height_preset === 'screen') $section_css .= "min-height: 100vh; display: flex; align-items: center;";
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>";
// Overlay
if ($overlay_opacity > 0) {
$opacity = $overlay_opacity / 100;
$html .= "<div class=\"wn-hero__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
}
// Element Styles
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$subtitle_style = self::generate_style_attr($element_styles['subtitle'] ?? []);
$cta_style = self::generate_style_attr($element_styles['cta_text'] ?? []); // Button
// Image (if not background)
if ($image && !$bg_image && $layout !== 'default') {
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />"; $html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
} }
$html .= '<div class="wn-hero__content">'; $html .= '<div class="wn-hero__content" style="position: relative; z-index: 10;">';
if ($title) { if ($title) {
$html .= "<h1 class=\"wn-hero__title\">{$title}</h1>"; $html .= "<h1 class=\"wn-hero__title\" {$title_style}>{$title}</h1>";
} }
if ($subtitle) { if ($subtitle) {
$html .= "<p class=\"wn-hero__subtitle\">{$subtitle}</p>"; $html .= "<p class=\"wn-hero__subtitle\" {$subtitle_style}>{$subtitle}</p>";
} }
if ($cta_text && $cta_url) { if ($cta_text && $cta_url) {
$html .= "<a href=\"{$cta_url}\" class=\"wn-hero__cta\">{$cta_text}</a>"; $html .= "<a href=\"{$cta_url}\" class=\"wn-hero__cta\" {$cta_style}>{$cta_text}</a>";
} }
$html .= '</div>'; $html .= '</div>';
$html .= '</section>'; $html .= '</section>';
@@ -128,50 +175,154 @@ class PageSSR
return $html; return $html;
} }
/**
* Universal Row Renderer (Shared logic for Content & ImageText)
*/
private static function render_universal_row($props, $layout, $color_scheme, $element_styles, $options = []) {
$title = esc_html($props['title']['value'] ?? ($props['title'] ?? ''));
$text = $props['text']['value'] ?? ($props['text'] ?? ($props['content']['value'] ?? ($props['content'] ?? ''))); // Handle both props/values
$image = esc_url($props['image']['value'] ?? ($props['image'] ?? ''));
// Options
$has_image = !empty($image);
$image_pos = $layout ?: 'left';
// Element Styles
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$text_style = self::generate_style_attr($element_styles['text'] ?? ($element_styles['content'] ?? []));
// Wrapper Classes
$wrapper_class = "wn-max-w-7xl wn-mx-auto wn-px-4";
$grid_class = "wn-mx-auto";
if ($has_image && in_array($image_pos, ['left', 'right', 'image-left', 'image-right'])) {
$grid_class .= " wn-grid wn-grid-cols-1 wn-lg-grid-cols-2 wn-gap-12 wn-items-center";
} else {
$grid_class .= " wn-max-w-4xl";
}
$html = "<div class=\"{$wrapper_class}\">";
$html .= "<div class=\"{$grid_class}\">";
// Image Output
$image_html = "";
if ($current_pos_right = ($image_pos === 'right' || $image_pos === 'image-right')) {
$order_class = 'wn-lg-order-last';
} else {
$order_class = 'wn-lg-order-first';
}
if ($has_image) {
$image_html = "<div class=\"wn-relative wn-w-full wn-aspect-[4/3] wn-rounded-2xl wn-overflow-hidden wn-shadow-lg {$order_class}\">";
$image_html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-absolute wn-inset-0 wn-w-full wn-h-full wn-object-cover\" />";
$image_html .= "</div>";
}
// Content Output
$content_html = "<div class=\"wn-flex wn-flex-col\">";
if ($title) {
$content_html .= "<h2 class=\"wn-text-3xl wn-font-bold wn-mb-6\" {$title_style}>{$title}</h2>";
}
if ($text) {
// Apply prose classes similar to React
$content_html .= "<div class=\"wn-prose wn-prose-lg wn-max-w-none\" {$text_style}>{$text}</div>";
}
$content_html .= "</div>";
// Render based on order (Grid handles order via CSS classes for left/right, but fallback for DOM order)
if ($has_image) {
// For grid layout, we output both. CSS order handles visual.
$html .= $image_html . $content_html;
} else {
$html .= $content_html;
}
$html .= "</div></div>";
return $html;
}
/** /**
* Render Content section (for post body, rich text) * Render Content section (for post body, rich text)
*/ */
public static function render_content($props, $layout, $color_scheme, $id) public static function render_content($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{ {
$content = $props['content'] ?? ''; $content = $props['content'] ?? '';
// Apply WordPress content filters (shortcodes, autop, etc.) // Apply WordPress content filters (shortcodes, autop, etc.)
$content = apply_filters('the_content', $content); $content = apply_filters('the_content', $content);
// Normalize prop structure for universal renderer if needed
if (is_string($props['content'])) {
$props['content'] = ['value' => $content];
} else {
$props['content']['value'] = $content;
}
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\">{$content}</section>"; // Section Styles (Background)
$bg_color = $section_styles['backgroundColor'] ?? '';
$padding = $section_styles['paddingTop'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? '';
$css = "";
if($bg_color) $css .= "background-color:{$bg_color};";
// Height Logic
if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem'; // Default padding for screen to avoid edge collision
} elseif ($height_preset === 'small') {
$padding = '2rem';
} elseif ($height_preset === 'large') {
$padding = '8rem';
} elseif ($height_preset === 'medium') {
$padding = '4rem';
}
if($padding) $css .= "padding:{$padding} 0;";
$style_attr = $css ? "style=\"{$css}\"" : "";
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles);
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
} }
/** /**
* Render Image + Text section * Render Image + Text section
*/ */
public static function render_image_text($props, $layout, $color_scheme, $id) public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{ {
$title = esc_html($props['title'] ?? ''); $bg_color = $section_styles['backgroundColor'] ?? '';
$text = wp_kses_post($props['text'] ?? ''); $padding = $section_styles['paddingTop'] ?? '';
$image = esc_url($props['image'] ?? ''); $height_preset = $section_styles['heightPreset'] ?? '';
$html = "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-image-text--{$layout} wn-scheme--{$color_scheme}\">"; $css = "";
if($bg_color) $css .= "background-color:{$bg_color};";
if ($image) { // Height Logic
$html .= "<div class=\"wn-image-text__image\"><img src=\"{$image}\" alt=\"{$title}\" /></div>"; if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem';
} elseif ($height_preset === 'small') {
$padding = '2rem';
} elseif ($height_preset === 'large') {
$padding = '8rem';
} elseif ($height_preset === 'medium') {
$padding = '4rem';
} }
$html .= '<div class="wn-image-text__content">'; if($padding) $css .= "padding:{$padding} 0;";
if ($title) { $style_attr = $css ? "style=\"{$css}\"" : "";
$html .= "<h2 class=\"wn-image-text__title\">{$title}</h2>";
}
if ($text) {
$html .= "<div class=\"wn-image-text__text\">{$text}</div>";
}
$html .= '</div>';
$html .= '</section>';
return $html; $inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles);
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
} }
/** /**
* Render Feature Grid section * Render Feature Grid section
*/ */
public static function render_feature_grid($props, $layout, $color_scheme, $id) public static function render_feature_grid($props, $layout, $color_scheme, $id, $element_styles = [])
{ {
$heading = esc_html($props['heading'] ?? ''); $heading = esc_html($props['heading'] ?? '');
$items = $props['items'] ?? []; $items = $props['items'] ?? [];
@@ -182,21 +333,36 @@ class PageSSR
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>"; $html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
} }
// Feature Item Styles (Card)
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here
$item_bg = $element_styles['feature_item']['backgroundColor'] ?? '';
$html .= '<div class="wn-feature-grid__items">'; $html .= '<div class="wn-feature-grid__items">';
foreach ($items as $item) { foreach ($items as $item) {
$item_title = esc_html($item['title'] ?? ''); $item_title = esc_html($item['title'] ?? '');
$item_desc = esc_html($item['description'] ?? ''); $item_desc = esc_html($item['description'] ?? '');
$item_icon = esc_html($item['icon'] ?? ''); $item_icon = esc_html($item['icon'] ?? '');
$html .= '<div class="wn-feature-grid__item">'; // Allow overriding item specific style if needed, but for now global
$html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>";
// Render Icon SVG
if ($item_icon) { if ($item_icon) {
$html .= "<span class=\"wn-feature-grid__icon\">{$item_icon}</span>"; $icon_svg = self::get_icon_svg($item_icon);
if ($icon_svg) {
$html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>";
} }
}
if ($item_title) { if ($item_title) {
$html .= "<h3 class=\"wn-feature-grid__item-title\">{$item_title}</h3>"; // Feature title style
$f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []);
$html .= "<h3 class=\"wn-feature-grid__item-title\" {$f_title_style}>{$item_title}</h3>";
} }
if ($item_desc) { if ($item_desc) {
$html .= "<p class=\"wn-feature-grid__item-desc\">{$item_desc}</p>"; // Feature description style
$f_desc_style = self::generate_style_attr($element_styles['feature_description'] ?? []);
$html .= "<p class=\"wn-feature-grid__item-desc\" {$f_desc_style}>{$item_desc}</p>";
} }
$html .= '</div>'; $html .= '</div>';
} }
@@ -209,7 +375,7 @@ class PageSSR
/** /**
* Render CTA Banner section * Render CTA Banner section
*/ */
public static function render_cta_banner($props, $layout, $color_scheme, $id) public static function render_cta_banner($props, $layout, $color_scheme, $id, $element_styles = [])
{ {
$title = esc_html($props['title'] ?? ''); $title = esc_html($props['title'] ?? '');
$text = esc_html($props['text'] ?? ''); $text = esc_html($props['text'] ?? '');
@@ -238,13 +404,29 @@ class PageSSR
/** /**
* Render Contact Form section * Render Contact Form section
*/ */
public static function render_contact_form($props, $layout, $color_scheme, $id) public static function render_contact_form($props, $layout, $color_scheme, $id, $element_styles = [])
{ {
$title = esc_html($props['title'] ?? ''); $title = esc_html($props['title'] ?? '');
$webhook_url = esc_url($props['webhook_url'] ?? ''); $webhook_url = esc_url($props['webhook_url'] ?? '');
$redirect_url = esc_url($props['redirect_url'] ?? ''); $redirect_url = esc_url($props['redirect_url'] ?? '');
$fields = $props['fields'] ?? ['name', 'email', 'message']; $fields = $props['fields'] ?? ['name', 'email', 'message'];
// Extract styles
$btn_bg = $element_styles['button']['backgroundColor'] ?? '';
$btn_color = $element_styles['button']['color'] ?? '';
$field_bg = $element_styles['fields']['backgroundColor'] ?? '';
$field_color = $element_styles['fields']['color'] ?? '';
$btn_style = "";
if ($btn_bg) $btn_style .= "background-color: {$btn_bg};";
if ($btn_color) $btn_style .= "color: {$btn_color};";
$btn_attr = $btn_style ? "style=\"{$btn_style}\"" : "";
$field_style = "";
if ($field_bg) $field_style .= "background-color: {$field_bg};";
if ($field_color) $field_style .= "color: {$field_color};";
$field_attr = $field_style ? "style=\"{$field_style}\"" : "";
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">"; $html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
if ($title) { if ($title) {
@@ -259,20 +441,39 @@ class PageSSR
$html .= '<div class="wn-contact-form__field">'; $html .= '<div class="wn-contact-form__field">';
$html .= "<label>{$field_label}</label>"; $html .= "<label>{$field_label}</label>";
if ($field === 'message') { if ($field === 'message') {
$html .= "<textarea name=\"{$field}\" placeholder=\"{$field_label}\"></textarea>"; $html .= "<textarea name=\"{$field}\" placeholder=\"{$field_label}\" {$field_attr}></textarea>";
} else { } else {
$html .= "<input type=\"text\" name=\"{$field}\" placeholder=\"{$field_label}\" />"; $html .= "<input type=\"text\" name=\"{$field}\" placeholder=\"{$field_label}\" {$field_attr} />";
} }
$html .= '</div>'; $html .= '</div>';
} }
$html .= '<button type="submit">Submit</button>'; $html .= "<button type=\"submit\" {$btn_attr}>Submit</button>";
$html .= '</form>'; $html .= '</form>';
$html .= '</section>'; $html .= '</section>';
return $html; return $html;
} }
/**
* Helper to get SVG for known icons
*/
private static function get_icon_svg($name) {
$icons = [
'Star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
'Zap' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
'Shield' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
'Heart' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
'Award' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>',
'Clock' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
'Truck' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>',
'User' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
'Settings' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
];
return $icons[$name] ?? $icons['Star'];
}
/** /**
* Generic section fallback * Generic section fallback
*/ */

View File

@@ -166,7 +166,14 @@ class TemplateOverride
'top' 'top'
); );
} else { } else {
// Rewrite /slug/anything to serve the SPA page // Rewrite /slug to serve the SPA page (base URL)
add_rewrite_rule(
'^' . preg_quote($spa_slug, '/') . '/?$',
'index.php?page_id=' . $spa_page_id,
'top'
);
// Rewrite /slug/anything to serve the SPA page with path
// React Router handles the path after that // React Router handles the path after that
add_rewrite_rule( add_rewrite_rule(
'^' . preg_quote($spa_slug, '/') . '/(.*)$', '^' . preg_quote($spa_slug, '/') . '/(.*)$',
@@ -306,8 +313,30 @@ class TemplateOverride
wp_redirect($build_route('my-account'), 302); wp_redirect($build_route('my-account'), 302);
exit; exit;
} }
// Redirect structural pages with WooNooW sections to SPA
if (is_singular('page') && $post) {
// Skip the SPA page itself and frontpage
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
return;
} }
// Check if page has WooNooW structure
$structure = get_post_meta($post->ID, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
// Redirect to SPA with page slug route
$page_slug = $post->post_name;
wp_redirect($build_route($page_slug), 302);
exit;
}
}
}
/**
* Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes
* and serve the SPA template directly (bypasses WooCommerce templates)
*/
/** /**
* Serve SPA template directly for frontpage SPA routes * Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes * When SPA page is set as WordPress frontpage, intercept known routes
@@ -331,8 +360,19 @@ class TemplateOverride
return; // SPA is not frontpage, let normal routing handle it return; // SPA is not frontpage, let normal routing handle it
} }
// Get the current request path // Get the current request path relative to site root
$request_uri = $_SERVER['REQUEST_URI'] ?? '/'; $request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$home_path = parse_url(home_url(), PHP_URL_PATH);
// Normalize request URI for subdirectory installs
if ($home_path && $home_path !== '/') {
$home_path = rtrim($home_path, '/');
if (strpos($request_uri, $home_path) === 0) {
$request_uri = substr($request_uri, strlen($home_path));
if (empty($request_uri)) $request_uri = '/';
}
}
$path = parse_url($request_uri, PHP_URL_PATH); $path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/'); $path = '/' . trim($path, '/');
@@ -365,6 +405,27 @@ class TemplateOverride
} }
} }
// Check for structural pages with WooNooW sections
if (!$should_serve_spa && !empty($path) && $path !== '/') {
// Try to find a page by slug matching the path
$slug = trim($path, '/');
// Handle nested slugs (get the last part as the page slug)
if (strpos($slug, '/') !== false) {
$slug_parts = explode('/', $slug);
$slug = end($slug_parts);
}
$page = get_page_by_path($slug);
if ($page) {
// Check if this page has WooNooW structure
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
$should_serve_spa = true;
}
}
}
// Not a SPA route // Not a SPA route
if (!$should_serve_spa) { if (!$should_serve_spa) {
return; return;
@@ -396,8 +457,8 @@ class TemplateOverride
*/ */
public static function disable_canonical_redirect($redirect_url, $requested_url) public static function disable_canonical_redirect($redirect_url, $requested_url)
{ {
$settings = get_option('woonoow_customer_spa_settings', []); $settings = get_option('woonoow_appearance_settings', []);
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled'; $mode = isset($settings['general']['spa_mode']) ? $settings['general']['spa_mode'] : 'disabled';
// Only disable redirects in full SPA mode // Only disable redirects in full SPA mode
if ($mode !== 'full') { if ($mode !== 'full') {
@@ -405,6 +466,7 @@ class TemplateOverride
} }
// Check if this is a SPA route // Check if this is a SPA route
// We include /product/ and standard endpoints
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account']; $spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
foreach ($spa_routes as $route) { foreach ($spa_routes as $route) {
@@ -733,6 +795,20 @@ class TemplateOverride
*/ */
public static function serve_ssr_content($page_id, $type = 'page', $post_obj = null) public static function serve_ssr_content($page_id, $type = 'page', $post_obj = null)
{ {
// Generate cache key
$cache_id = $post_obj ? $post_obj->ID : $page_id;
$cache_key = "wn_ssr_{$type}_{$cache_id}";
// Check cache TTL (default 1 hour, filterable)
$cache_ttl = apply_filters('woonoow_ssr_cache_ttl', HOUR_IN_SECONDS);
// Try to get cached content
$cached = get_transient($cache_key);
if ($cached !== false) {
echo $cached;
exit;
}
// Get page structure // Get page structure
if ($type === 'page') { if ($type === 'page') {
$structure = get_post_meta($page_id, '_wn_page_structure', true); $structure = get_post_meta($page_id, '_wn_page_structure', true);
@@ -783,7 +859,8 @@ class TemplateOverride
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30); wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
} }
// Output SSR HTML // Output SSR HTML - start output buffering for caching
ob_start();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html <?php language_attributes(); ?>> <html <?php language_attributes(); ?>>
@@ -825,6 +902,14 @@ class TemplateOverride
</body> </body>
</html> </html>
<?php <?php
// Get buffered output
$output = ob_get_clean();
// Cache the output for bots (uses cache TTL from filter)
set_transient($cache_key, $output, $cache_ttl);
// Output and exit
echo $output;
exit; exit;
} }

View File

@@ -0,0 +1,373 @@
<?php
namespace WooNooW\Setup;
/**
* Default Pages Setup
* Creates default pages with WooNooW structure on plugin activation
*/
class DefaultPages
{
/**
* Ensure all default pages exist
*/
public static function create_pages()
{
self::create_home_page();
self::create_about_page();
self::create_contact_page();
self::create_legal_pages();
self::create_woocommerce_pages();
self::ensure_spa_settings();
}
/**
* Ensure SPA Settings are configured
*/
private static function ensure_spa_settings()
{
$settings = get_option('woonoow_appearance_settings', []);
// Ensure General array exists
if (!isset($settings['general'])) {
$settings['general'] = [];
}
// Enable SPA mode if not set
if (empty($settings['general']['spa_mode']) || $settings['general']['spa_mode'] === 'disabled') {
$settings['general']['spa_mode'] = 'full';
}
// Set SPA Root Page if missing (prioritize Home, then Shop)
if (empty($settings['general']['spa_page'])) {
$home_page = get_page_by_path('home');
$shop_page_id = get_option('woocommerce_shop_page_id');
if ($home_page) {
// If Home exists, make it the Front Page AND SPA Root
$settings['general']['spa_page'] = $home_page->ID;
update_option('show_on_front', 'page');
update_option('page_on_front', $home_page->ID);
} elseif ($shop_page_id) {
// Fallback to Shop
$settings['general']['spa_page'] = $shop_page_id;
}
}
update_option('woonoow_appearance_settings', $settings);
}
/**
* Create Home Page with Rich Layout
*/
private static function create_home_page()
{
$slug = 'home';
$title = 'Home';
if (self::page_exists($slug)) {
return;
}
$structure = [
'type' => 'page',
'sections' => [
[
'id' => 'section-hero-home',
'type' => 'hero',
'layoutVariant' => 'default',
'colorScheme' => 'primary',
'props' => [
'title' => ['type' => 'static', 'value' => 'Welcome onto WooNooW'],
'subtitle' => ['type' => 'static', 'value' => 'Discover our premium collection of products tailored just for you. Quality meets style in every item.'],
'cta_text' => ['type' => 'static', 'value' => 'Shop Now'],
'cta_url' => ['type' => 'static', 'value' => '/shop'],
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=1600&q=80'],
],
'elementStyles' => [
'title' => ['fontSize' => 'text-5xl', 'fontWeight' => 'font-bold'],
]
],
[
'id' => 'section-features-home',
'type' => 'feature-grid',
'layoutVariant' => 'grid-3',
'props' => [
'heading' => ['type' => 'static', 'value' => 'Why Choose Us'],
'features' => ['type' => 'static', 'value' => json_encode([
['icon' => 'truck', 'title' => 'Free Shipping', 'description' => 'On all orders over $50'],
['icon' => 'shield', 'title' => 'Secure Payment', 'description' => '100% secure payment processing'],
['icon' => 'clock', 'title' => '24/7 Support', 'description' => 'Dedicated support anytime you need'],
])]
]
],
[
'id' => 'section-story-home',
'type' => 'image-text',
'layoutVariant' => 'image-left',
'props' => [
'title' => ['type' => 'static', 'value' => 'Our Story'],
'text' => ['type' => 'static', 'value' => 'Founded with a passion for quality and design, we strive to bring you products that elevate your everyday life. Every item is carefully curated and inspected to ensure it meets our high standards.'],
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&q=80'],
]
],
[
'id' => 'section-cta-home',
'type' => 'cta-banner',
'colorScheme' => 'muted',
'props' => [
'title' => ['type' => 'static', 'value' => 'Ready to start shopping?'],
'text' => ['type' => 'static', 'value' => 'Join thousands of satisfied customers today.'],
'button_text' => ['type' => 'static', 'value' => 'View Catalog'],
'button_url' => ['type' => 'static', 'value' => '/shop'],
]
]
],
'created_at' => current_time('mysql'),
];
self::insert_page($title, $slug, $structure);
}
/**
* Create About Us Page
*/
private static function create_about_page()
{
$slug = 'about';
$title = 'About Us';
if (self::page_exists($slug)) {
return;
}
$structure = [
'type' => 'page',
'sections' => [
[
'id' => 'section-hero-about',
'type' => 'hero',
'layoutVariant' => 'centered',
'colorScheme' => 'secondary',
'props' => [
'title' => ['type' => 'static', 'value' => 'About Us'],
'subtitle' => ['type' => 'static', 'value' => 'Learn more about our journey and mission.'],
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1600&q=80'],
]
],
[
'id' => 'section-story-about',
'type' => 'image-text',
'layoutVariant' => 'image-right',
'props' => [
'title' => ['type' => 'static', 'value' => 'Who We Are'],
'text' => ['type' => 'static', 'value' => 'We are a team of passionate individuals dedicated to providing the best shopping experience. Our mission is to make quality products accessible to everyone.'],
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1556761175-5973dc0f32e7?w=800&q=80'],
]
],
[
'id' => 'section-values-about',
'type' => 'feature-grid',
'layoutVariant' => 'grid-3',
'props' => [
'heading' => ['type' => 'static', 'value' => 'Our Core Values'],
'features' => ['type' => 'static', 'value' => json_encode([
['icon' => 'heart', 'title' => 'Passion', 'description' => 'We love what we do'],
['icon' => 'star', 'title' => 'Excellence', 'description' => 'We aim for the best'],
['icon' => 'users', 'title' => 'Community', 'description' => 'We build relationships'],
])]
]
]
],
'created_at' => current_time('mysql'),
];
self::insert_page($title, $slug, $structure);
}
/**
* Create Contact Page
*/
private static function create_contact_page()
{
$slug = 'contact';
$title = 'Contact Us';
if (self::page_exists($slug)) {
return;
}
$structure = [
'type' => 'page',
'sections' => [
[
'id' => 'section-hero-contact',
'type' => 'hero',
'layoutVariant' => 'default',
'colorScheme' => 'gradient',
'props' => [
'title' => ['type' => 'static', 'value' => 'Get in Touch'],
'subtitle' => ['type' => 'static', 'value' => 'Have questions? We are here to help.'],
]
],
[
'id' => 'section-form-contact',
'type' => 'contact-form',
'props' => [
'title' => ['type' => 'static', 'value' => 'Send us a Message'],
]
]
],
'created_at' => current_time('mysql'),
];
self::insert_page($title, $slug, $structure);
}
/**
* Create Legal Pages
*/
private static function create_legal_pages()
{
$pages = [
'privacy-policy' => 'Privacy Policy',
'terms-conditions' => 'Terms & Conditions',
];
foreach ($pages as $slug => $title) {
if (self::page_exists($slug)) {
continue;
}
$content = "<h2>{$title}</h2><p>This is a placeholder for your {$title}. Please update this content with your actual legal text.</p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>";
$structure = [
'type' => 'page',
'sections' => [
[
'id' => 'section-content-' . $slug,
'type' => 'content',
'layoutVariant' => 'narrow',
'props' => [
'content' => ['type' => 'static', 'value' => $content],
]
]
],
'created_at' => current_time('mysql'),
];
self::insert_page($title, $slug, $structure);
// If privacy policy, link it in WP settings
if ($slug === 'privacy-policy') {
$page = get_page_by_path($slug);
if ($page) {
update_option('wp_page_for_privacy_policy', $page->ID);
}
}
}
}
/**
* Create WooCommerce Pages (Shop, Cart, Checkout, My Account)
*/
private static function create_woocommerce_pages()
{
if (!class_exists('WooCommerce')) {
return;
}
$wc_pages = [
'shop' => ['title' => 'Shop', 'content' => '', 'option' => 'woocommerce_shop_page_id'],
'cart' => ['title' => 'Cart', 'content' => '<!-- wp:shortcode -->[woocommerce_cart]<!-- /wp:shortcode -->', 'option' => 'woocommerce_cart_page_id'],
'checkout' => ['title' => 'Checkout', 'content' => '<!-- wp:shortcode -->[woocommerce_checkout]<!-- /wp:shortcode -->', 'option' => 'woocommerce_checkout_page_id'],
'my-account' => ['title' => 'My Account', 'content' => '<!-- wp:shortcode -->[woocommerce_my_account]<!-- /wp:shortcode -->', 'option' => 'woocommerce_myaccount_page_id'],
];
foreach ($wc_pages as $slug => $data) {
// Check if page is already assigned in WC options
$existing_id = get_option($data['option']);
if ($existing_id && get_post($existing_id)) {
continue;
}
// Check if page exists by slug
if (self::page_exists($slug)) {
$page = get_page_by_path($slug);
update_option($data['option'], $page->ID);
continue;
}
// Create page
$page_id = wp_insert_post([
'post_title' => $data['title'],
'post_name' => $slug,
'post_content' => $data['content'],
'post_status' => 'publish',
'post_type' => 'page',
]);
if ($page_id && !is_wp_error($page_id)) {
update_option($data['option'], $page_id);
// For Shop page, add a fallback structure (though content-product takes precedence in SPA)
if ($slug === 'shop') {
$structure = [
'type' => 'page',
'sections' => [
[
'id' => 'section-shop-products',
'type' => 'content',
'props' => [
'content' => ['type' => 'static', 'value' => '[products limit="12" columns="4"]'],
]
]
],
'created_at' => current_time('mysql'),
];
update_post_meta($page_id, '_wn_page_structure', $structure);
} else {
// For other pages, add structre that wraps the shortcode for SPA rendering
$structure = [
'type' => 'page',
'sections' => [
[
'id' => 'section-' . $slug,
'type' => 'content',
'props' => [
'content' => ['type' => 'static', 'value' => $data['content']],
]
]
],
'created_at' => current_time('mysql'),
];
update_post_meta($page_id, '_wn_page_structure', $structure);
}
}
}
}
/**
* Helper: Check if page exists
*/
private static function page_exists($slug)
{
return !empty(get_page_by_path($slug));
}
/**
* Helper: Insert Page and Structure
*/
private static function insert_page($title, $slug, $structure)
{
$page_id = wp_insert_post([
'post_title' => $title,
'post_name' => $slug,
'post_status' => 'publish',
'post_type' => 'page',
]);
if ($page_id && !is_wp_error($page_id)) {
update_post_meta($page_id, '_wn_page_structure', $structure);
}
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace WooNooW\Templates;
defined('ABSPATH') || exit;
class TemplateRegistry
{
/**
* Get all available templates
*/
public static function get_templates()
{
return [
[
'id' => 'blank',
'label' => 'Blank Page',
'description' => 'Start from scratch with an empty page.',
'icon' => 'file',
'sections' => []
],
[
'id' => 'landing-page',
'label' => 'Landing Page',
'description' => 'High-converting landing page with Hero, Features, and CTA.',
'icon' => 'layout',
'sections' => self::get_landing_page_structure()
],
[
'id' => 'about-us',
'label' => 'About Us',
'description' => 'Tell your story with image-text layouts and content.',
'icon' => 'users',
'sections' => self::get_about_us_structure()
],
[
'id' => 'contact',
'label' => 'Contact',
'description' => 'Simple contact page with a form and address details.',
'icon' => 'mail',
'sections' => self::get_contact_structure()
]
];
}
/**
* Get a specific template by ID
*/
public static function get_template($id)
{
$templates = self::get_templates();
foreach ($templates as $template) {
if ($template['id'] === $id) {
return $template;
}
}
return null;
}
/**
* Helper to generate a unique ID
*/
private static function generate_id()
{
return uniqid('section_');
}
private static function get_landing_page_structure()
{
return [
[
'id' => self::generate_id(),
'type' => 'hero',
'props' => [
'title' => ['type' => 'static', 'value' => 'Welcome to Our Service'],
'subtitle' => ['type' => 'static', 'value' => 'We create amazing digital experiences for your business.'],
'cta_text' => ['type' => 'static', 'value' => 'Get Started'],
'cta_url' => ['type' => 'static', 'value' => '#'],
'image' => ['type' => 'static', 'value' => ''],
],
'styles' => ['contentWidth' => 'full']
],
[
'id' => self::generate_id(),
'type' => 'feature-grid',
'props' => [
'heading' => ['type' => 'static', 'value' => 'Why Choose Us'],
'features' => ['type' => 'static', 'value' => [
['title' => 'Fast Delivery', 'description' => 'Quick shipping to your doorstep'],
['title' => 'Secure Payment', 'description' => 'Your data is always protected'],
['title' => 'Quality Products', 'description' => 'Only the best for our customers']
]]
],
'styles' => ['contentWidth' => 'contained']
],
[
'id' => self::generate_id(),
'type' => 'cta-banner',
'props' => [
'title' => ['type' => 'static', 'value' => 'Ready to Launch?'],
'text' => ['type' => 'static', 'value' => 'Join thousands of satisfied customers today.'],
'button_text' => ['type' => 'static', 'value' => 'Sign Up Now'],
'button_url' => ['type' => 'static', 'value' => '#']
],
'styles' => ['contentWidth' => 'full']
]
];
}
private static function get_about_us_structure()
{
return [
[
'id' => self::generate_id(),
'type' => 'image-text',
'layoutVariant' => 'image-left',
'props' => [
'title' => ['type' => 'static', 'value' => 'Our Story'],
'text' => ['type' => 'static', 'value' => 'We started with a simple mission: to make web design accessible to everyone. Our journey began in a small garage...'],
'image' => ['type' => 'static', 'value' => '']
],
'styles' => ['contentWidth' => 'contained']
],
[
'id' => self::generate_id(),
'type' => 'image-text',
'layoutVariant' => 'image-right',
'props' => [
'title' => ['type' => 'static', 'value' => 'Our Vision'],
'text' => ['type' => 'static', 'value' => 'To empower businesses of all sizes to have a professional online presence without the technical headache.'],
'image' => ['type' => 'static', 'value' => '']
],
'styles' => ['contentWidth' => 'contained']
],
[
'id' => self::generate_id(),
'type' => 'content',
'props' => [
'content' => ['type' => 'static', 'value' => '<h3>Meet the Team</h3><p>Our diverse team of designers and developers...</p>']
],
'styles' => ['contentWidth' => 'contained']
]
];
}
private static function get_contact_structure()
{
return [
[
'id' => self::generate_id(),
'type' => 'content',
'props' => [
'content' => ['type' => 'static', 'value' => '<h2>Get in Touch</h2><p>We are here to help and answer any question you might have.</p><p><strong>Address:</strong><br>123 Web Street<br>Tech City, TC 90210</p>']
],
'styles' => ['contentWidth' => 'contained']
],
[
'id' => self::generate_id(),
'type' => 'contact-form',
'props' => [
'title' => ['type' => 'static', 'value' => 'Send us a Message'],
'webhook_url' => ['type' => 'static', 'value' => ''],
'redirect_url' => ['type' => 'static', 'value' => '']
],
'styles' => ['contentWidth' => 'contained']
]
];
}
}

View File

@@ -20,8 +20,14 @@
} else { } else {
// Full SPA mode starts at shop // Full SPA mode starts at shop
$page_type = 'shop'; $page_type = 'shop';
// If this is the front page, route to /
if (is_front_page()) {
$data_attrs = 'data-page="shop" data-initial-route="/"';
} else {
$data_attrs = 'data-page="shop" data-initial-route="/shop"'; $data_attrs = 'data-page="shop" data-initial-route="/shop"';
} }
}
?> ?>
<div id="woonoow-customer-app" <?php echo $data_attrs; ?>> <div id="woonoow-customer-app" <?php echo $data_attrs; ?>>

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,11 @@ add_action('plugins_loaded', function () {
}); });
// Activation/Deactivation hooks // Activation/Deactivation hooks
register_activation_hook(__FILE__, ['WooNooW\Core\Installer', 'activate']); // Activation/Deactivation hooks
register_activation_hook(__FILE__, function() {
WooNooW\Core\Installer::activate();
WooNooW\Setup\DefaultPages::create_pages();
});
register_deactivation_hook(__FILE__, ['WooNooW\Core\Installer', 'deactivate']); register_deactivation_hook(__FILE__, ['WooNooW\Core\Installer', 'deactivate']);
// Dev mode filters removed - use wp-config.php if needed: // Dev mode filters removed - use wp-config.php if needed: