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

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

View File

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

View File

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

View File

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

View File

@@ -14,24 +14,24 @@ interface BlockRendererProps {
isLast: boolean; isLast: boolean;
} }
export function BlockRenderer({ export function BlockRenderer({
block, block,
isEditing, isEditing,
onEdit, onEdit,
onDelete, onDelete,
onMoveUp, onMoveUp,
onMoveDown, onMoveDown,
isFirst, isFirst,
isLast isLast
}: BlockRendererProps) { }: BlockRendererProps) {
// Prevent navigation in builder // Prevent navigation in builder
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (
target.tagName === 'A' || target.tagName === 'A' ||
target.tagName === 'BUTTON' || target.tagName === 'BUTTON' ||
target.closest('a') || target.closest('a') ||
target.closest('button') || target.closest('button') ||
target.classList.contains('button') || target.classList.contains('button') ||
target.classList.contains('button-outline') || target.classList.contains('button-outline') ||
@@ -42,7 +42,7 @@ export function BlockRenderer({
e.stopPropagation(); e.stopPropagation();
} }
}; };
const renderBlockContent = () => { const renderBlockContent = () => {
switch (block.type) { switch (block.type) {
case 'card': case 'card':
@@ -75,48 +75,48 @@ export function BlockRenderer({
marginBottom: '24px' marginBottom: '24px'
}, },
hero: { hero: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)',
color: '#fff', color: '#fff',
borderRadius: '8px', borderRadius: '8px',
padding: '32px 40px', padding: '32px 40px',
marginBottom: '24px' marginBottom: '24px'
} }
}; };
// Convert markdown to HTML for visual rendering // Convert markdown to HTML for visual rendering
const htmlContent = parseMarkdownBasics(block.content); const htmlContent = parseMarkdownBasics(block.content);
return ( return (
<div style={cardStyles[block.cardType]}> <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" 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' } : {}} style={block.cardType === 'hero' ? { color: '#fff' } : {}}
dangerouslySetInnerHTML={{ __html: htmlContent }} dangerouslySetInnerHTML={{ __html: htmlContent }}
/> />
</div> </div>
); );
case 'button': { case 'button': {
const buttonStyle: React.CSSProperties = block.style === 'solid' const buttonStyle: React.CSSProperties = block.style === 'solid'
? { ? {
display: 'inline-block', display: 'inline-block',
background: '#7f54b3', background: 'var(--wn-primary, #7f54b3)',
color: '#fff', color: '#fff',
padding: '14px 28px', padding: '14px 28px',
borderRadius: '6px', borderRadius: '6px',
textDecoration: 'none', textDecoration: 'none',
fontWeight: 600, fontWeight: 600,
} }
: { : {
display: 'inline-block', display: 'inline-block',
background: 'transparent', background: 'transparent',
color: '#7f54b3', color: 'var(--wn-secondary, #7f54b3)',
padding: '12px 26px', padding: '12px 26px',
border: '2px solid #7f54b3', border: '2px solid var(--wn-secondary, #7f54b3)',
borderRadius: '6px', borderRadius: '6px',
textDecoration: 'none', textDecoration: 'none',
fontWeight: 600, fontWeight: 600,
}; };
const containerStyle: React.CSSProperties = { const containerStyle: React.CSSProperties = {
textAlign: block.align || 'center', textAlign: block.align || 'center',
@@ -130,7 +130,7 @@ export function BlockRenderer({
buttonStyle.maxWidth = `${block.customMaxWidth}px`; buttonStyle.maxWidth = `${block.customMaxWidth}px`;
buttonStyle.width = '100%'; buttonStyle.width = '100%';
} }
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
<a href={block.link} style={buttonStyle}> <a href={block.link} style={buttonStyle}>
@@ -166,13 +166,13 @@ export function BlockRenderer({
</div> </div>
); );
} }
case 'divider': case 'divider':
return <hr className="border-t border-gray-300 my-4" />; return <hr className="border-t border-gray-300 my-4" />;
case 'spacer': case 'spacer':
return <div style={{ height: `${block.height}px` }} />; return <div style={{ height: `${block.height}px` }} />;
default: default:
return null; return null;
} }
@@ -184,7 +184,7 @@ export function BlockRenderer({
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}> <div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
{renderBlockContent()} {renderBlockContent()}
</div> </div>
{/* Hover Controls */} {/* 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"> <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 && ( {!isFirst && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,13 +36,15 @@ export default function AppearanceGeneral() {
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' }, friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' }, elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
}; };
const [colors, setColors] = useState({ const [colors, setColors] = useState({
primary: '#1a1a1a', primary: '#1a1a1a',
secondary: '#6b7280', secondary: '#6b7280',
accent: '#3b82f6', accent: '#3b82f6',
text: '#111827', text: '#111827',
background: '#ffffff', background: '#ffffff',
gradientStart: '#9333ea', // purple-600 defaults
gradientEnd: '#3b82f6', // blue-500 defaults
}); });
useEffect(() => { useEffect(() => {
@@ -51,7 +53,7 @@ export default function AppearanceGeneral() {
// Load appearance settings // Load appearance settings
const response = await api.get('/appearance/settings'); const response = await api.get('/appearance/settings');
const general = response.data?.general; const general = response.data?.general;
if (general) { if (general) {
if (general.spa_mode) setSpaMode(general.spa_mode); if (general.spa_mode) setSpaMode(general.spa_mode);
if (general.spa_page) setSpaPage(general.spa_page || 0); if (general.spa_page) setSpaPage(general.spa_page || 0);
@@ -70,10 +72,12 @@ export default function AppearanceGeneral() {
accent: general.colors.accent || '#3b82f6', accent: general.colors.accent || '#3b82f6',
text: general.colors.text || '#111827', text: general.colors.text || '#111827',
background: general.colors.background || '#ffffff', background: general.colors.background || '#ffffff',
gradientStart: general.colors.gradientStart || '#9333ea',
gradientEnd: general.colors.gradientEnd || '#3b82f6',
}); });
} }
} }
// Load available pages // Load available pages
const pagesResponse = await api.get('/pages/list'); const pagesResponse = await api.get('/pages/list');
console.log('Pages API response:', pagesResponse); console.log('Pages API response:', pagesResponse);
@@ -90,7 +94,7 @@ export default function AppearanceGeneral() {
setLoading(false); setLoading(false);
} }
}; };
loadSettings(); loadSettings();
}, []); }, []);
@@ -108,7 +112,7 @@ export default function AppearanceGeneral() {
}, },
colors, colors,
}); });
toast.success('General settings saved successfully'); toast.success('General settings saved successfully');
} catch (error) { } catch (error) {
console.error('Save error:', error); console.error('Save error:', error);
@@ -139,7 +143,7 @@ export default function AppearanceGeneral() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<RadioGroupItem value="checkout_only" id="spa-checkout" /> <RadioGroupItem value="checkout_only" id="spa-checkout" />
<div className="space-y-1"> <div className="space-y-1">
@@ -151,7 +155,7 @@ export default function AppearanceGeneral() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<RadioGroupItem value="full" id="spa-full" /> <RadioGroupItem value="full" id="spa-full" />
<div className="space-y-1"> <div className="space-y-1">
@@ -175,14 +179,14 @@ export default function AppearanceGeneral() {
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <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. The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<SettingsSection label="SPA Entry Page" htmlFor="spa-page"> <SettingsSection label="SPA Entry Page" htmlFor="spa-page">
<Select <Select
value={spaPage.toString()} value={spaPage.toString()}
onValueChange={(value) => setSpaPage(parseInt(value))} onValueChange={(value) => setSpaPage(parseInt(value))}
> >
<SelectTrigger id="spa-page"> <SelectTrigger id="spa-page">
@@ -246,7 +250,7 @@ export default function AppearanceGeneral() {
<p className="text-sm text-muted-foreground mb-3"> <p className="text-sm text-muted-foreground mb-3">
Self-hosted fonts, no external requests Self-hosted fonts, no external requests
</p> </p>
{typographyMode === 'predefined' && ( {typographyMode === 'predefined' && (
<Select value={predefinedPair} onValueChange={setPredefinedPair}> <Select value={predefinedPair} onValueChange={setPredefinedPair}>
<SelectTrigger className="w-full min-w-[300px] [&>span]:line-clamp-none [&>span]:whitespace-normal"> <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> </div>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<RadioGroupItem value="custom_google" id="typo-custom" /> <RadioGroupItem value="custom_google" id="typo-custom" />
<div className="space-y-1 flex-1"> <div className="space-y-1 flex-1">
@@ -297,7 +301,7 @@ export default function AppearanceGeneral() {
Using Google Fonts may not be GDPR compliant Using Google Fonts may not be GDPR compliant
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{typographyMode === 'custom_google' && ( {typographyMode === 'custom_google' && (
<div className="space-y-3 mt-3"> <div className="space-y-3 mt-3">
<SettingsSection label="Heading Font" htmlFor="heading-font"> <SettingsSection label="Heading Font" htmlFor="heading-font">
@@ -321,7 +325,7 @@ export default function AppearanceGeneral() {
</div> </div>
</div> </div>
</RadioGroup> </RadioGroup>
<div className="space-y-3 pt-4 border-t"> <div className="space-y-3 pt-4 border-t">
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label> <Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
<Slider <Slider
@@ -345,18 +349,18 @@ export default function AppearanceGeneral() {
> >
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(colors).map(([key, value]) => ( {Object.entries(colors).map(([key, value]) => (
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1)} htmlFor={`color-${key}`}> <SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')} htmlFor={`color-${key}`}>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id={`color-${key}`} id={`color-${key}`}
type="color" type="color"
value={value} value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })} onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
className="w-20 h-10 cursor-pointer" className="w-20 h-10 cursor-pointer"
/> />
<Input <Input
type="text" type="text"
value={value} value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })} onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
className="flex-1 font-mono" className="flex-1 font-mono"
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,25 +37,35 @@ interface AppearanceSettings {
thankyou: any; thankyou: any;
account: any; account: any;
}; };
menus: {
primary: Array<{
id: string;
label: string;
type: 'page' | 'custom';
value: string;
target: '_self' | '_blank';
}>;
mobile: Array<any>;
};
} }
export function useAppearanceSettings() { export function useAppearanceSettings() {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1'; const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
// Get preloaded settings from window object // Get preloaded settings from window object
const preloadedSettings = (window as any).woonoowCustomer?.appearanceSettings; const preloadedSettings = (window as any).woonoowCustomer?.appearanceSettings;
return useQuery<AppearanceSettings>({ return useQuery<AppearanceSettings>({
queryKey: ['appearance-settings'], queryKey: ['appearance-settings'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${apiRoot}/appearance/settings`, { const response = await fetch(`${apiRoot}/appearance/settings`, {
credentials: 'include', credentials: 'include',
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch appearance settings'); throw new Error('Failed to fetch appearance settings');
} }
const data = await response.json(); const data = await response.json();
return data.data; return data.data;
}, },
@@ -68,7 +78,7 @@ export function useAppearanceSettings() {
export function useShopSettings() { export function useShopSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
const defaultSettings = { const defaultSettings = {
layout: { layout: {
grid_columns: '3' as string, grid_columns: '3' as string,
@@ -93,7 +103,7 @@ export function useShopSettings() {
show_icon: true, show_icon: true,
}, },
}; };
return { return {
layout: { ...defaultSettings.layout, ...(data?.pages?.shop?.layout || {}) }, layout: { ...defaultSettings.layout, ...(data?.pages?.shop?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.shop?.elements || {}) }, elements: { ...defaultSettings.elements, ...(data?.pages?.shop?.elements || {}) },
@@ -105,7 +115,7 @@ export function useShopSettings() {
export function useProductSettings() { export function useProductSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
const defaultSettings = { const defaultSettings = {
layout: { layout: {
image_position: 'left' as string, image_position: 'left' as string,
@@ -127,7 +137,7 @@ export function useProductSettings() {
hide_if_empty: true, hide_if_empty: true,
}, },
}; };
return { return {
layout: { ...defaultSettings.layout, ...(data?.pages?.product?.layout || {}) }, layout: { ...defaultSettings.layout, ...(data?.pages?.product?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.product?.elements || {}) }, elements: { ...defaultSettings.elements, ...(data?.pages?.product?.elements || {}) },
@@ -139,7 +149,7 @@ export function useProductSettings() {
export function useCartSettings() { export function useCartSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
const defaultSettings = { const defaultSettings = {
layout: { layout: {
style: 'fullwidth' as string, style: 'fullwidth' as string,
@@ -152,7 +162,7 @@ export function useCartSettings() {
shipping_calculator: false, shipping_calculator: false,
}, },
}; };
return { return {
layout: { ...defaultSettings.layout, ...(data?.pages?.cart?.layout || {}) }, layout: { ...defaultSettings.layout, ...(data?.pages?.cart?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.cart?.elements || {}) }, elements: { ...defaultSettings.elements, ...(data?.pages?.cart?.elements || {}) },
@@ -162,7 +172,7 @@ export function useCartSettings() {
export function useCheckoutSettings() { export function useCheckoutSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
const defaultSettings = { const defaultSettings = {
layout: { layout: {
style: 'two-column' as string, style: 'two-column' as string,
@@ -178,7 +188,7 @@ export function useCheckoutSettings() {
payment_icons: true, payment_icons: true,
}, },
}; };
return { return {
layout: { ...defaultSettings.layout, ...(data?.pages?.checkout?.layout || {}) }, layout: { ...defaultSettings.layout, ...(data?.pages?.checkout?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.checkout?.elements || {}) }, elements: { ...defaultSettings.elements, ...(data?.pages?.checkout?.elements || {}) },
@@ -188,7 +198,7 @@ export function useCheckoutSettings() {
export function useThankYouSettings() { export function useThankYouSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
const defaultSettings = { const defaultSettings = {
template: 'basic', template: 'basic',
header_visibility: 'show', header_visibility: 'show',
@@ -201,7 +211,7 @@ export function useThankYouSettings() {
related_products: false, related_products: false,
}, },
}; };
return { return {
template: data?.pages?.thankyou?.template || defaultSettings.template, template: data?.pages?.thankyou?.template || defaultSettings.template,
headerVisibility: data?.pages?.thankyou?.header_visibility || defaultSettings.header_visibility, headerVisibility: data?.pages?.thankyou?.header_visibility || defaultSettings.header_visibility,
@@ -215,7 +225,7 @@ export function useThankYouSettings() {
export function useAccountSettings() { export function useAccountSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
const defaultSettings = { const defaultSettings = {
layout: { layout: {
navigation_style: 'sidebar' as string, navigation_style: 'sidebar' as string,
@@ -228,7 +238,7 @@ export function useAccountSettings() {
account_details: true, account_details: true,
}, },
}; };
return { return {
layout: { ...defaultSettings.layout, ...(data?.pages?.account?.layout || {}) }, layout: { ...defaultSettings.layout, ...(data?.pages?.account?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.account?.elements || {}) }, elements: { ...defaultSettings.elements, ...(data?.pages?.account?.elements || {}) },
@@ -238,7 +248,7 @@ export function useAccountSettings() {
export function useHeaderSettings() { export function useHeaderSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
return { return {
style: data?.header?.style ?? 'classic', style: data?.header?.style ?? 'classic',
sticky: data?.header?.sticky ?? true, sticky: data?.header?.sticky ?? true,
@@ -261,7 +271,7 @@ export function useHeaderSettings() {
export function useFooterSettings() { export function useFooterSettings() {
const { data, isLoading } = useAppearanceSettings(); const { data, isLoading } = useAppearanceSettings();
return { return {
columns: data?.footer?.columns ?? '4', columns: data?.footer?.columns ?? '4',
style: data?.footer?.style ?? 'detailed', style: data?.footer?.style ?? 'detailed',
@@ -293,4 +303,15 @@ export function useFooterSettings() {
}, },
isLoading, isLoading,
}; };
};
export function useMenuSettings() {
const { data, isLoading } = useAppearanceSettings();
return {
primary: data?.menus?.primary ?? [],
mobile: data?.menus?.mobile ?? [],
isLoading,
};
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,13 @@
} else { } else {
// Full SPA mode starts at shop // Full SPA mode starts at shop
$page_type = 'shop'; $page_type = 'shop';
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
// If this is the front page, route to /
if (is_front_page()) {
$data_attrs = 'data-page="shop" data-initial-route="/"';
} else {
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
}
} }
?> ?>

File diff suppressed because it is too large Load Diff

View File

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