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"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.46.3",
@@ -2898,6 +2899,33 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"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": {
"version": "5.90.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",

View File

@@ -59,6 +59,7 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@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 AppearanceThankYou from '@/routes/Appearance/ThankYou';
import AppearanceAccount from '@/routes/Appearance/Account';
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
import AppearancePages from '@/routes/Appearance/Pages';
import MarketingIndex from '@/routes/Marketing';
import Newsletter from '@/routes/Marketing/Newsletter';
@@ -628,6 +629,7 @@ function AppRoutes() {
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
<Route path="/appearance/account" element={<AppearanceAccount />} />
<Route path="/appearance/menus" element={<AppearanceMenus />} />
<Route path="/appearance/pages" element={<AppearancePages />} />
{/* Marketing */}

View File

@@ -14,24 +14,24 @@ interface BlockRendererProps {
isLast: boolean;
}
export function BlockRenderer({
block,
isEditing,
onEdit,
onDelete,
onMoveUp,
export function BlockRenderer({
block,
isEditing,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast
isLast
}: BlockRendererProps) {
// Prevent navigation in builder
const handleClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
target.closest('a') ||
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
target.closest('a') ||
target.closest('button') ||
target.classList.contains('button') ||
target.classList.contains('button-outline') ||
@@ -42,7 +42,7 @@ export function BlockRenderer({
e.stopPropagation();
}
};
const renderBlockContent = () => {
switch (block.type) {
case 'card':
@@ -75,48 +75,48 @@ export function BlockRenderer({
marginBottom: '24px'
},
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',
borderRadius: '8px',
padding: '32px 40px',
marginBottom: '24px'
}
};
// Convert markdown to HTML for visual rendering
const htmlContent = parseMarkdownBasics(block.content);
return (
<div style={cardStyles[block.cardType]}>
<div
<div
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
);
case 'button': {
const buttonStyle: React.CSSProperties = block.style === 'solid'
? {
display: 'inline-block',
background: '#7f54b3',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
}
display: 'inline-block',
background: 'var(--wn-primary, #7f54b3)',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
}
: {
display: 'inline-block',
background: 'transparent',
color: '#7f54b3',
padding: '12px 26px',
border: '2px solid #7f54b3',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
};
display: 'inline-block',
background: 'transparent',
color: 'var(--wn-secondary, #7f54b3)',
padding: '12px 26px',
border: '2px solid var(--wn-secondary, #7f54b3)',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
};
const containerStyle: React.CSSProperties = {
textAlign: block.align || 'center',
@@ -130,7 +130,7 @@ export function BlockRenderer({
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
buttonStyle.width = '100%';
}
return (
<div style={containerStyle}>
<a href={block.link} style={buttonStyle}>
@@ -166,13 +166,13 @@ export function BlockRenderer({
</div>
);
}
case 'divider':
return <hr className="border-t border-gray-300 my-4" />;
case 'spacer':
return <div style={{ height: `${block.height}px` }} />;
default:
return null;
}
@@ -184,7 +184,7 @@ export function BlockRenderer({
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
{renderBlockContent()}
</div>
{/* Hover Controls */}
<div className="absolute -right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col gap-1 bg-white rounded-md shadow-lg border p-1">
{!isFirst && (

View File

@@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
if (block.type === 'card') {
// Convert markdown to HTML for rich text editor
const htmlContent = parseMarkdownBasics(block.content);
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
setEditingContent(htmlContent);
setEditingCardType(block.cardType);
} 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,
}),
// ButtonExtension MUST come before Link to ensure buttons are parsed first
ButtonExtension,
Link.configure({
openOnClick: false,
HTMLAttributes: {
@@ -65,7 +67,6 @@ export function RichTextEditor({
class: 'max-w-full h-auto rounded',
},
}),
ButtonExtension,
],
content,
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. */
label: React.ReactNode;
/** Optional text used for filtering. Falls back to string label or value. */
searchText?: string;
triggerLabel?: React.ReactNode;
}
interface Props {
@@ -55,7 +55,7 @@ export function SearchableSelect({
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
return (
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}>
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -65,7 +65,7 @@ export function SearchableSelect({
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
>
{selected ? selected.label : placeholder}
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
</Button>
</PopoverTrigger>

View File

@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
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" &&
"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

View File

@@ -39,6 +39,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
return [
{
tag: 'a[data-button]',
priority: 100, // Higher priority than Link extension (default 50)
getAttrs: (node: HTMLElement) => ({
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
@@ -47,6 +48,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
},
{
tag: 'a.button',
priority: 100,
getAttrs: (node: HTMLElement) => ({
text: node.textContent || 'Click Here',
href: node.getAttribute('href') || '#',
@@ -55,6 +57,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
},
{
tag: 'a.button-outline',
priority: 100,
getAttrs: (node: HTMLElement) => ({
text: node.textContent || 'Click Here',
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 {
isStandalone: boolean;
@@ -7,15 +7,44 @@ interface AppContextType {
const AppContext = createContext<AppContextType | undefined>(undefined);
export function AppProvider({
children,
isStandalone,
exitFullscreen
}: {
children: ReactNode;
isStandalone: boolean;
export function AppProvider({
children,
isStandalone,
exitFullscreen
}: {
children: ReactNode;
isStandalone: boolean;
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 (
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
{children}

View File

@@ -68,8 +68,23 @@ export function htmlToMarkdown(html: string): string {
}).join('\n') + '\n\n';
});
// Paragraphs - convert to double newlines
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
// Paragraphs - preserve text-align by using placeholders
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
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
@@ -80,6 +95,11 @@ export function htmlToMarkdown(html: string): string {
// Remove remaining HTML tags
markdown = markdown.replace(/<[^>]+>/g, '');
// Restore aligned paragraphs
Object.entries(alignedParagraphs).forEach(([placeholder, html]) => {
markdown = markdown.replace(placeholder, html);
});
// Clean up excessive newlines
markdown = markdown.replace(/\n{3,}/g, '\n\n');

View File

@@ -36,13 +36,15 @@ export default function AppearanceGeneral() {
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
};
const [colors, setColors] = useState({
primary: '#1a1a1a',
secondary: '#6b7280',
accent: '#3b82f6',
text: '#111827',
background: '#ffffff',
gradientStart: '#9333ea', // purple-600 defaults
gradientEnd: '#3b82f6', // blue-500 defaults
});
useEffect(() => {
@@ -51,7 +53,7 @@ export default function AppearanceGeneral() {
// Load appearance settings
const response = await api.get('/appearance/settings');
const general = response.data?.general;
if (general) {
if (general.spa_mode) setSpaMode(general.spa_mode);
if (general.spa_page) setSpaPage(general.spa_page || 0);
@@ -70,10 +72,12 @@ export default function AppearanceGeneral() {
accent: general.colors.accent || '#3b82f6',
text: general.colors.text || '#111827',
background: general.colors.background || '#ffffff',
gradientStart: general.colors.gradientStart || '#9333ea',
gradientEnd: general.colors.gradientEnd || '#3b82f6',
});
}
}
// Load available pages
const pagesResponse = await api.get('/pages/list');
console.log('Pages API response:', pagesResponse);
@@ -90,7 +94,7 @@ export default function AppearanceGeneral() {
setLoading(false);
}
};
loadSettings();
}, []);
@@ -108,7 +112,7 @@ export default function AppearanceGeneral() {
},
colors,
});
toast.success('General settings saved successfully');
} catch (error) {
console.error('Save error:', error);
@@ -139,7 +143,7 @@ export default function AppearanceGeneral() {
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="checkout_only" id="spa-checkout" />
<div className="space-y-1">
@@ -151,7 +155,7 @@ export default function AppearanceGeneral() {
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="full" id="spa-full" />
<div className="space-y-1">
@@ -175,14 +179,14 @@ export default function AppearanceGeneral() {
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This page will render the full SPA to the body element with no theme interference.
This page will render the full SPA to the body element with no theme interference.
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
</AlertDescription>
</Alert>
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
<Select
value={spaPage.toString()}
<Select
value={spaPage.toString()}
onValueChange={(value) => setSpaPage(parseInt(value))}
>
<SelectTrigger id="spa-page">
@@ -246,7 +250,7 @@ export default function AppearanceGeneral() {
<p className="text-sm text-muted-foreground mb-3">
Self-hosted fonts, no external requests
</p>
{typographyMode === 'predefined' && (
<Select value={predefinedPair} onValueChange={setPredefinedPair}>
<SelectTrigger className="w-full min-w-[300px] [&>span]:line-clamp-none [&>span]:whitespace-normal">
@@ -284,7 +288,7 @@ export default function AppearanceGeneral() {
)}
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="custom_google" id="typo-custom" />
<div className="space-y-1 flex-1">
@@ -297,7 +301,7 @@ export default function AppearanceGeneral() {
Using Google Fonts may not be GDPR compliant
</AlertDescription>
</Alert>
{typographyMode === 'custom_google' && (
<div className="space-y-3 mt-3">
<SettingsSection label="Heading Font" htmlFor="heading-font">
@@ -321,7 +325,7 @@ export default function AppearanceGeneral() {
</div>
</div>
</RadioGroup>
<div className="space-y-3 pt-4 border-t">
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
<Slider
@@ -345,18 +349,18 @@ export default function AppearanceGeneral() {
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{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">
<Input
id={`color-${key}`}
type="color"
value={value}
value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
type="text"
value={value}
value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
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 { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
@@ -35,6 +35,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
const [pageType, setPageType] = useState<'page' | 'template'>('page');
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
// Prevent double submission
const isSubmittingRef = useRef(false);
@@ -42,9 +43,18 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
// Get site URL from WordPress config
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
// Fetch templates
const { data: templates = [] } = useQuery({
queryKey: ['templates-presets'],
queryFn: async () => {
const res = await api.get('/templates/presets');
return res as { id: string; label: string; description: string; icon: string }[];
}
});
// Create page mutation
const createMutation = useMutation({
mutationFn: async (data: { title: string; slug: string }) => {
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
// Guard against double submission
if (isSubmittingRef.current) {
throw new Error('Request already in progress');
@@ -53,7 +63,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
try {
// api.post returns JSON directly (not wrapped in { data: ... })
const response = await api.post('/pages', { title: data.title, slug: data.slug });
const response = await api.post('/pages', {
title: data.title,
slug: data.slug,
templateId: data.templateId
});
return response; // Return response directly, not response.data
} finally {
// Reset after a delay to prevent race conditions
@@ -74,6 +88,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
onOpenChange(false);
setTitle('');
setSlug('');
setSelectedTemplateId('blank');
}
},
onError: (error: any) => {
@@ -105,7 +120,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
return;
}
if (pageType === 'page' && title && slug) {
createMutation.mutate({ title, slug });
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
}
};
@@ -115,6 +130,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
setTitle('');
setSlug('');
setPageType('page');
setSelectedTemplateId('blank');
isSubmittingRef.current = false;
}
}, [open]);
@@ -123,7 +139,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{__('Create New Page')}</DialogTitle>
<DialogDescription>
@@ -133,8 +149,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
<div className="space-y-6 py-4 px-1">
{/* Page Type Selection */}
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')}>
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer hover:bg-accent/50 transition-colors">
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
<div
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
onClick={() => setPageType('page')}
>
<RadioGroupItem value="page" id="page" className="mt-1" />
<div className="flex-1">
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
@@ -147,7 +166,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
</div>
</div>
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50">
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
<div className="flex-1">
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
@@ -163,30 +182,59 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
{/* Page Details */}
{pageType === 'page' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{__('Page Title')}</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={__('e.g., About Us')}
disabled={createMutation.isPending}
/>
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">{__('Page Title')}</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={__('e.g., About Us')}
disabled={createMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">{__('URL Slug')}</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder={__('e.g., about-us')}
disabled={createMutation.isPending}
/>
<p className="text-xs text-muted-foreground truncate">
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="slug">{__('URL Slug')}</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder={__('e.g., about-us')}
disabled={createMutation.isPending}
/>
<p className="text-xs text-muted-foreground">
{__('URL will be: ')}<span className="font-mono text-primary">{siteUrl}/{slug || 'page-slug'}</span>
</p>
<div className="space-y-3">
<Label>{__('Choose a Template')}</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{templates.map((tpl) => (
<div
key={tpl.id}
className={`
relative p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
${selectedTemplateId === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
`}
onClick={() => setSelectedTemplateId(tpl.id)}
>
<div className="mb-2 font-medium text-sm flex items-center gap-2">
{tpl.label}
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{tpl.description}
</p>
</div>
))}
{templates.length === 0 && (
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
{__('Loading templates...')}
</div>
)}
</div>
</div>
</div>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/
import { CodeEditor } from '@/components/ui/code-editor';
import { Label } from '@/components/ui/label';
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 { __ } from '@/lib/i18n';
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 [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({
queryKey: ['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
const { data: template, isLoading, error } = useQuery({
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)
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
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 primaryColor = settings.primary_color || '#7f54b3';
const secondaryColor = settings.secondary_color || '#7f54b3';
const heroGradientStart = settings.hero_gradient_start || '#667eea';
const heroGradientEnd = settings.hero_gradient_end || '#764ba2';
const heroTextColor = settings.hero_text_color || '#ffffff';
const buttonTextColor = settings.button_text_color || '#ffffff';
const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
const primaryColor = appearColors.primary || '#7f54b3';
const secondaryColor = appearColors.secondary || '#7f54b3';
const heroGradientStart = appearColors.gradientStart || '#667eea';
const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
const heroTextColor = '#ffffff'; // Always white on gradient
const buttonTextColor = '#ffffff'; // Always white on primary
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
const socialIconColor = settings.social_icon_color || 'white';
const logoUrl = settings.logo_url || '';
@@ -307,10 +345,11 @@ export default function EditTemplate() {
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
// Generate social icons HTML with PNG images
// Get plugin URL from config, with fallback
const pluginUrl =
(window as any).woonoowData?.pluginUrl ||
(window as any).WNW_CONFIG?.pluginUrl ||
'';
'/wp-content/plugins/woonoow/';
const socialIconsHtml = socialLinks.length > 0 ? `
<div style="margin-top: 16px;">
${socialLinks.map((link: any) => `
@@ -414,128 +453,175 @@ export default function EditTemplate() {
}
return (
<SettingsLayout
title={template.event_label || __('Edit Template')}
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
onSave={handleSave}
saveLabel={__('Save Template')}
isLoading={false}
action={
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// Determine if staff or customer based on event category
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
const page = isStaffEvent ? 'staff' : 'customer';
navigate(`/settings/notifications/${page}?tab=events`);
}}
className="gap-2"
title={__('Back')}
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">{__('Back')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="gap-2"
title={__('Reset to Default')}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{__('Reset to Default')}</span>
</Button>
</div>
}
>
<Card>
<CardContent className="pt-6 space-y-6">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
/>
<p className="text-xs text-muted-foreground">
{channelId === 'email'
? __('Email subject line')
: __('Push notification title')}
</p>
<>
<SettingsLayout
title={template.event_label || __('Edit Template')}
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
onSave={handleSave}
saveLabel={__('Save Template')}
isLoading={false}
action={
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// Determine if staff or customer based on event category
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
const page = isStaffEvent ? 'staff' : 'customer';
navigate(`/settings/notifications/${page}?tab=events`);
}}
className="gap-2"
title={__('Back')}
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">{__('Back')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="gap-2"
title={__('Reset to Default')}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{__('Reset to Default')}</span>
</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>
{/* Body */}
<div className="space-y-4">
{/* Three-tab system: Preview | Visual | Markdown */}
<div className="flex items-center justify-between">
<Label>{__('Message Body')}</Label>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
<TabsList className="grid grid-cols-3">
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
<Eye className="h-3 w-3" />
{__('Preview')}
</TabsTrigger>
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
<Edit className="h-3 w-3" />
{__('Visual')}
</TabsTrigger>
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
<FileText className="h-3 w-3" />
{__('Markdown')}
</TabsTrigger>
</TabsList>
</Tabs>
}
>
<Card>
<CardContent className="pt-6 space-y-6">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
/>
<p className="text-xs text-muted-foreground">
{channelId === 'email'
? __('Email subject line')
: __('Push notification title')}
</p>
</div>
{/* Preview Tab */}
{activeTab === 'preview' && (
<div className="border rounded-md overflow-hidden">
<iframe
srcDoc={generatePreviewHTML()}
className="w-full min-h-[600px] overflow-hidden bg-white"
title={__('Email Preview')}
/>
{/* Body */}
<div className="space-y-4">
{/* Three-tab system: Preview | Visual | Markdown */}
<div className="flex items-center justify-between">
<Label>{__('Message Body')}</Label>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
<TabsList className="grid grid-cols-3">
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
<Eye className="h-3 w-3" />
{__('Preview')}
</TabsTrigger>
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
<Edit className="h-3 w-3" />
{__('Visual')}
</TabsTrigger>
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
<FileText className="h-3 w-3" />
{__('Markdown')}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{/* Visual Tab */}
{activeTab === 'visual' && (
<div>
<EmailBuilder
blocks={blocks}
onChange={handleBlocksChange}
variables={variableKeys}
/>
<p className="text-xs text-muted-foreground mt-2">
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
</p>
</div>
)}
{/* Preview Tab */}
{activeTab === 'preview' && (
<div className="border rounded-md overflow-hidden">
<iframe
srcDoc={generatePreviewHTML()}
className="w-full min-h-[600px] overflow-hidden bg-white"
title={__('Email Preview')}
/>
</div>
)}
{/* Markdown Tab */}
{activeTab === 'markdown' && (
<div className="space-y-2">
<CodeEditor
value={markdownContent}
onChange={handleMarkdownChange}
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
supportMarkdown={true}
/>
<p className="text-xs text-muted-foreground">
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
</p>
<p className="text-xs text-muted-foreground">
{__('All changes are automatically synced between Visual and Markdown modes.')}
</p>
</div>
)}
{/* Visual Tab */}
{activeTab === 'visual' && (
<div>
<EmailBuilder
blocks={blocks}
onChange={handleBlocksChange}
variables={variableKeys}
/>
<p className="text-xs text-muted-foreground mt-2">
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
</p>
</div>
)}
{/* Markdown Tab */}
{activeTab === 'markdown' && (
<div className="space-y-2">
<CodeEditor
value={markdownContent}
onChange={handleMarkdownChange}
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
supportMarkdown={true}
/>
<p className="text-xs text-muted-foreground">
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
</p>
<p className="text-xs text-muted-foreground">
{__('All changes are automatically synced between Visual and Markdown modes.')}
</p>
</div>
)}
</div>
</CardContent>
</Card>
</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>
</CardContent>
</Card>
</SettingsLayout>
<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,190 +219,22 @@ export default function EmailCustomization() {
}
>
<div className="space-y-6">
{/* Brand Colors */}
{/* Unified Colors Notice */}
<SettingsCard
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="space-y-2">
<Label htmlFor="primary_color">{__('Primary Color')}</Label>
<div className="flex gap-2">
<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>
</div>
<div className="space-y-2">
<Label htmlFor="secondary_color">{__('Secondary Color')}</Label>
<div className="flex gap-2">
<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>
</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)')}
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-900 dark:text-blue-100">
<strong>{__('Colors are now unified!')}</strong>{' '}
{__('Email colors (buttons, gradients) now use the same colors as your storefront for consistent branding.')}
</p>
<p className="text-sm text-blue-900 dark:text-blue-100 mt-2">
{__('To change colors, go to')}{' '}
<a href="#/appearance/general" className="font-medium underline hover:no-underline">
{__('Appearance → General → Colors')}
</a>
</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>
@@ -540,7 +372,7 @@ export default function EmailCustomization() {
{__('Add Social Link')}
</Button>
</div>
{formData.social_links.length === 0 ? (
<p className="text-sm text-muted-foreground">
{__('No social links added. Click "Add Social Link" to get started.')}

View File

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