Fix button roundtrip in editor, alignment persistence, and test email rendering
This commit is contained in:
28
admin-spa/package-lock.json
generated
28
admin-spa/package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
@@ -2898,6 +2899,33 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
|
||||
@@ -280,6 +280,7 @@ import AppearanceCart from '@/routes/Appearance/Cart';
|
||||
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
|
||||
import AppearancePages from '@/routes/Appearance/Pages';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||
@@ -628,6 +629,7 @@ function AppRoutes() {
|
||||
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
||||
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
||||
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
||||
<Route path="/appearance/menus" element={<AppearanceMenus />} />
|
||||
<Route path="/appearance/pages" element={<AppearancePages />} />
|
||||
|
||||
{/* Marketing */}
|
||||
|
||||
@@ -14,24 +14,24 @@ interface BlockRendererProps {
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst,
|
||||
isLast
|
||||
isLast
|
||||
}: BlockRendererProps) {
|
||||
|
||||
|
||||
// Prevent navigation in builder
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.closest('button') ||
|
||||
target.classList.contains('button') ||
|
||||
target.classList.contains('button-outline') ||
|
||||
@@ -42,7 +42,7 @@ export function BlockRenderer({
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const renderBlockContent = () => {
|
||||
switch (block.type) {
|
||||
case 'card':
|
||||
@@ -75,48 +75,48 @@ export function BlockRenderer({
|
||||
marginBottom: '24px'
|
||||
},
|
||||
hero: {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)',
|
||||
color: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '32px 40px',
|
||||
marginBottom: '24px'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Convert markdown to HTML for visual rendering
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
|
||||
|
||||
return (
|
||||
<div style={cardStyles[block.cardType]}>
|
||||
<div
|
||||
<div
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
|
||||
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
case 'button': {
|
||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||
? {
|
||||
display: 'inline-block',
|
||||
background: '#7f54b3',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}
|
||||
display: 'inline-block',
|
||||
background: 'var(--wn-primary, #7f54b3)',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}
|
||||
: {
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: '#7f54b3',
|
||||
padding: '12px 26px',
|
||||
border: '2px solid #7f54b3',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: 'var(--wn-secondary, #7f54b3)',
|
||||
padding: '12px 26px',
|
||||
border: '2px solid var(--wn-secondary, #7f54b3)',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align || 'center',
|
||||
@@ -130,7 +130,7 @@ export function BlockRenderer({
|
||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
buttonStyle.width = '100%';
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<a href={block.link} style={buttonStyle}>
|
||||
@@ -166,13 +166,13 @@ export function BlockRenderer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
case 'divider':
|
||||
return <hr className="border-t border-gray-300 my-4" />;
|
||||
|
||||
|
||||
case 'spacer':
|
||||
return <div style={{ height: `${block.height}px` }} />;
|
||||
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export function BlockRenderer({
|
||||
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
|
||||
{renderBlockContent()}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Hover Controls */}
|
||||
<div className="absolute -right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col gap-1 bg-white rounded-md shadow-lg border p-1">
|
||||
{!isFirst && (
|
||||
|
||||
@@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
if (block.type === 'card') {
|
||||
// Convert markdown to HTML for rich text editor
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
||||
setEditingContent(htmlContent);
|
||||
setEditingCardType(block.cardType);
|
||||
} else if (block.type === 'button') {
|
||||
|
||||
77
admin-spa/src/components/MediaUploader.tsx
Normal file
77
admin-spa/src/components/MediaUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,8 @@ export function RichTextEditor({
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
||||
ButtonExtension,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
@@ -65,7 +67,6 @@ export function RichTextEditor({
|
||||
class: 'max-w-full h-auto rounded',
|
||||
},
|
||||
}),
|
||||
ButtonExtension,
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface Option {
|
||||
/** What to render in the button/list. Can be a string or React node. */
|
||||
label: React.ReactNode;
|
||||
/** Optional text used for filtering. Falls back to string label or value. */
|
||||
searchText?: string;
|
||||
triggerLabel?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -55,7 +55,7 @@ export function SearchableSelect({
|
||||
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
||||
|
||||
return (
|
||||
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}>
|
||||
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -65,7 +65,7 @@ export function SearchableSelect({
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{selected ? selected.label : placeholder}
|
||||
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
|
||||
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
"relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@@ -39,6 +39,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
return [
|
||||
{
|
||||
tag: 'a[data-button]',
|
||||
priority: 100, // Higher priority than Link extension (default 50)
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||
@@ -47,6 +48,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
},
|
||||
{
|
||||
tag: 'a.button',
|
||||
priority: 100,
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
@@ -55,6 +57,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
},
|
||||
{
|
||||
tag: 'a.button-outline',
|
||||
priority: 100,
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, ReactNode, useEffect } from 'react';
|
||||
|
||||
interface AppContextType {
|
||||
isStandalone: boolean;
|
||||
@@ -7,15 +7,44 @@ interface AppContextType {
|
||||
|
||||
const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||
|
||||
export function AppProvider({
|
||||
children,
|
||||
isStandalone,
|
||||
exitFullscreen
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isStandalone: boolean;
|
||||
export function AppProvider({
|
||||
children,
|
||||
isStandalone,
|
||||
exitFullscreen
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isStandalone: boolean;
|
||||
exitFullscreen?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Fetch and apply appearance settings (colors)
|
||||
const loadAppearance = async () => {
|
||||
try {
|
||||
const restUrl = (window as any).WNW_CONFIG?.restUrl || '';
|
||||
const response = await fetch(`${restUrl}/appearance/settings`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// API returns { success: true, data: { general: { colors: {...} } } }
|
||||
const colors = result.data?.general?.colors;
|
||||
if (colors) {
|
||||
const root = document.documentElement;
|
||||
// Inject all color settings as CSS variables
|
||||
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
|
||||
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
|
||||
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
|
||||
if (colors.text) root.style.setProperty('--wn-text', colors.text);
|
||||
if (colors.background) root.style.setProperty('--wn-background', colors.background);
|
||||
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
|
||||
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load appearance settings', e);
|
||||
}
|
||||
};
|
||||
loadAppearance();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
|
||||
{children}
|
||||
|
||||
@@ -68,8 +68,23 @@ export function htmlToMarkdown(html: string): string {
|
||||
}).join('\n') + '\n\n';
|
||||
});
|
||||
|
||||
// Paragraphs - convert to double newlines
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
|
||||
// Paragraphs - preserve text-align by using placeholders
|
||||
const alignedParagraphs: { [key: string]: string } = {};
|
||||
let alignIndex = 0;
|
||||
markdown = markdown.replace(/<p([^>]*)>(.*?)<\/p>/gis, (match, attrs, content) => {
|
||||
// Check for text-align in style attribute
|
||||
const alignMatch = attrs.match(/text-align:\s*(center|right)/i);
|
||||
if (alignMatch) {
|
||||
const align = alignMatch[1].toLowerCase();
|
||||
// Use double-bracket placeholder that won't be matched by HTML regex
|
||||
const placeholder = `[[ALIGN${alignIndex}]]`;
|
||||
alignedParagraphs[placeholder] = `<p style="text-align: ${align};">${content}</p>`;
|
||||
alignIndex++;
|
||||
return placeholder + '\n\n';
|
||||
}
|
||||
// No alignment, convert to plain text
|
||||
return `${content}\n\n`;
|
||||
});
|
||||
|
||||
// Line breaks
|
||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||
@@ -80,6 +95,11 @@ export function htmlToMarkdown(html: string): string {
|
||||
// Remove remaining HTML tags
|
||||
markdown = markdown.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Restore aligned paragraphs
|
||||
Object.entries(alignedParagraphs).forEach(([placeholder, html]) => {
|
||||
markdown = markdown.replace(placeholder, html);
|
||||
});
|
||||
|
||||
// Clean up excessive newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
|
||||
@@ -36,13 +36,15 @@ export default function AppearanceGeneral() {
|
||||
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
|
||||
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
|
||||
};
|
||||
|
||||
|
||||
const [colors, setColors] = useState({
|
||||
primary: '#1a1a1a',
|
||||
secondary: '#6b7280',
|
||||
accent: '#3b82f6',
|
||||
text: '#111827',
|
||||
background: '#ffffff',
|
||||
gradientStart: '#9333ea', // purple-600 defaults
|
||||
gradientEnd: '#3b82f6', // blue-500 defaults
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +53,7 @@ export default function AppearanceGeneral() {
|
||||
// Load appearance settings
|
||||
const response = await api.get('/appearance/settings');
|
||||
const general = response.data?.general;
|
||||
|
||||
|
||||
if (general) {
|
||||
if (general.spa_mode) setSpaMode(general.spa_mode);
|
||||
if (general.spa_page) setSpaPage(general.spa_page || 0);
|
||||
@@ -70,10 +72,12 @@ export default function AppearanceGeneral() {
|
||||
accent: general.colors.accent || '#3b82f6',
|
||||
text: general.colors.text || '#111827',
|
||||
background: general.colors.background || '#ffffff',
|
||||
gradientStart: general.colors.gradientStart || '#9333ea',
|
||||
gradientEnd: general.colors.gradientEnd || '#3b82f6',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load available pages
|
||||
const pagesResponse = await api.get('/pages/list');
|
||||
console.log('Pages API response:', pagesResponse);
|
||||
@@ -90,7 +94,7 @@ export default function AppearanceGeneral() {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
@@ -108,7 +112,7 @@ export default function AppearanceGeneral() {
|
||||
},
|
||||
colors,
|
||||
});
|
||||
|
||||
|
||||
toast.success('General settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
@@ -139,7 +143,7 @@ export default function AppearanceGeneral() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="checkout_only" id="spa-checkout" />
|
||||
<div className="space-y-1">
|
||||
@@ -151,7 +155,7 @@ export default function AppearanceGeneral() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="full" id="spa-full" />
|
||||
<div className="space-y-1">
|
||||
@@ -175,14 +179,14 @@ export default function AppearanceGeneral() {
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This page will render the full SPA to the body element with no theme interference.
|
||||
This page will render the full SPA to the body element with no theme interference.
|
||||
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
||||
<Select
|
||||
value={spaPage.toString()}
|
||||
<Select
|
||||
value={spaPage.toString()}
|
||||
onValueChange={(value) => setSpaPage(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger id="spa-page">
|
||||
@@ -246,7 +250,7 @@ export default function AppearanceGeneral() {
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Self-hosted fonts, no external requests
|
||||
</p>
|
||||
|
||||
|
||||
{typographyMode === 'predefined' && (
|
||||
<Select value={predefinedPair} onValueChange={setPredefinedPair}>
|
||||
<SelectTrigger className="w-full min-w-[300px] [&>span]:line-clamp-none [&>span]:whitespace-normal">
|
||||
@@ -284,7 +288,7 @@ export default function AppearanceGeneral() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="custom_google" id="typo-custom" />
|
||||
<div className="space-y-1 flex-1">
|
||||
@@ -297,7 +301,7 @@ export default function AppearanceGeneral() {
|
||||
Using Google Fonts may not be GDPR compliant
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
{typographyMode === 'custom_google' && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<SettingsSection label="Heading Font" htmlFor="heading-font">
|
||||
@@ -321,7 +325,7 @@ export default function AppearanceGeneral() {
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
|
||||
<Slider
|
||||
@@ -345,18 +349,18 @@ export default function AppearanceGeneral() {
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(colors).map(([key, value]) => (
|
||||
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1)} htmlFor={`color-${key}`}>
|
||||
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')} htmlFor={`color-${key}`}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`color-${key}`}
|
||||
type="color"
|
||||
value={value}
|
||||
value={value as string}
|
||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||
className="w-20 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
value={value as string}
|
||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
|
||||
495
admin-spa/src/routes/Appearance/Menus/MenuEditor.tsx
Normal file
495
admin-spa/src/routes/Appearance/Menus/MenuEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -35,6 +35,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
const [pageType, setPageType] = useState<'page' | 'template'>('page');
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
|
||||
|
||||
// Prevent double submission
|
||||
const isSubmittingRef = useRef(false);
|
||||
@@ -42,9 +43,18 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
// Get site URL from WordPress config
|
||||
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
|
||||
|
||||
// Fetch templates
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['templates-presets'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/templates/presets');
|
||||
return res as { id: string; label: string; description: string; icon: string }[];
|
||||
}
|
||||
});
|
||||
|
||||
// Create page mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { title: string; slug: string }) => {
|
||||
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
|
||||
// Guard against double submission
|
||||
if (isSubmittingRef.current) {
|
||||
throw new Error('Request already in progress');
|
||||
@@ -53,7 +63,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
try {
|
||||
// api.post returns JSON directly (not wrapped in { data: ... })
|
||||
const response = await api.post('/pages', { title: data.title, slug: data.slug });
|
||||
const response = await api.post('/pages', {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
templateId: data.templateId
|
||||
});
|
||||
return response; // Return response directly, not response.data
|
||||
} finally {
|
||||
// Reset after a delay to prevent race conditions
|
||||
@@ -74,6 +88,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
onOpenChange(false);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setSelectedTemplateId('blank');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -105,7 +120,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
return;
|
||||
}
|
||||
if (pageType === 'page' && title && slug) {
|
||||
createMutation.mutate({ title, slug });
|
||||
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,6 +130,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setPageType('page');
|
||||
setSelectedTemplateId('blank');
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
}, [open]);
|
||||
@@ -123,7 +139,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Create New Page')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -133,8 +149,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
<div className="space-y-6 py-4 px-1">
|
||||
{/* Page Type Selection */}
|
||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')}>
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer hover:bg-accent/50 transition-colors">
|
||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
|
||||
onClick={() => setPageType('page')}
|
||||
>
|
||||
<RadioGroupItem value="page" id="page" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
@@ -147,7 +166,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50">
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
|
||||
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
@@ -163,30 +182,59 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
{/* Page Details */}
|
||||
{pageType === 'page' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={__('e.g., About Us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={__('e.g., About Us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder={__('e.g., about-us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder={__('e.g., about-us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('URL will be: ')}<span className="font-mono text-primary">{siteUrl}/{slug || 'page-slug'}</span>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Label>{__('Choose a Template')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`
|
||||
relative p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||
${selectedTemplateId === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||
`}
|
||||
onClick={() => setSelectedTemplateId(tpl.id)}
|
||||
>
|
||||
<div className="mb-2 font-medium text-sm flex items-center gap-2">
|
||||
{tpl.label}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tpl.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
|
||||
{__('Loading templates...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FileText, Layout, Loader2 } from 'lucide-react';
|
||||
import { FileText, Layout, Loader2, Home } from 'lucide-react';
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
@@ -51,14 +51,19 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
|
||||
key={`page-${page.id}`}
|
||||
onClick={() => onSelectPage(page)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between group',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.id === page.id && selectedPage?.type === 'page'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
{page.title}
|
||||
<span className="truncate">{page.title}</span>
|
||||
{(page as any).isSpaLanding && (
|
||||
<span title="SPA Landing Page" className="flex-shrink-0 ml-2">
|
||||
<Home className="w-3.5 h-3.5 text-green-600" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,17 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
@@ -158,18 +169,36 @@ function SortableSectionCard({
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
if (confirm(__('Delete this section?'))) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="z-[60]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Plus, FileText, Layout, Trash2, Eye, Settings } from 'lucide-react';
|
||||
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PageSidebar } from './components/PageSidebar';
|
||||
import { SectionEditor } from './components/SectionEditor';
|
||||
import { PageSettings } from './components/PageSettings';
|
||||
import { CanvasRenderer } from './components/CanvasRenderer';
|
||||
import { InspectorPanel } from './components/InspectorPanel';
|
||||
import { CreatePageModal } from './components/CreatePageModal';
|
||||
import { usePageEditorStore, Section } from './store/usePageEditorStore';
|
||||
|
||||
// Types
|
||||
interface PageItem {
|
||||
@@ -23,75 +23,109 @@ interface PageItem {
|
||||
icon?: string;
|
||||
has_template?: boolean;
|
||||
permalink_base?: string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PageStructure {
|
||||
type: 'page' | 'template';
|
||||
sections: Section[];
|
||||
updated_at?: string;
|
||||
isFrontPage?: boolean;
|
||||
}
|
||||
|
||||
export default function AppearancePages() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPage, setSelectedPage] = useState<PageItem | null>(null);
|
||||
const [selectedSection, setSelectedSection] = useState<Section | null>(null);
|
||||
const [structure, setStructure] = useState<PageStructure | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Zustand store
|
||||
const {
|
||||
currentPage,
|
||||
sections,
|
||||
selectedSectionId,
|
||||
deviceMode,
|
||||
inspectorCollapsed,
|
||||
hasUnsavedChanges,
|
||||
isLoading,
|
||||
availableSources,
|
||||
setCurrentPage,
|
||||
setSections,
|
||||
setSelectedSection,
|
||||
setDeviceMode,
|
||||
setInspectorCollapsed,
|
||||
setAvailableSources,
|
||||
setIsLoading,
|
||||
addSection,
|
||||
deleteSection,
|
||||
duplicateSection,
|
||||
moveSection,
|
||||
reorderSections,
|
||||
updateSectionProp,
|
||||
updateSectionLayout,
|
||||
updateSectionColorScheme,
|
||||
updateSectionStyles,
|
||||
updateElementStyles,
|
||||
markAsSaved,
|
||||
setAsSpaLanding,
|
||||
unsetSpaLanding,
|
||||
} = usePageEditorStore();
|
||||
|
||||
// Get selected section object
|
||||
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
|
||||
|
||||
// Fetch all pages and templates
|
||||
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
||||
queryKey: ['pages'],
|
||||
queryFn: async () => {
|
||||
// api.get returns JSON directly (not wrapped in { data: ... })
|
||||
const response = await api.get('/pages');
|
||||
return response; // Return response directly
|
||||
// Map API snake_case to frontend camelCase
|
||||
return response.map((p: any) => ({
|
||||
...p,
|
||||
isSpaLanding: !!p.is_spa_frontpage
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch selected page/template structure
|
||||
const { data: pageData, isLoading: pageLoading } = useQuery({
|
||||
queryKey: ['page-structure', selectedPage?.type, selectedPage?.slug || selectedPage?.cpt],
|
||||
queryKey: ['page-structure', currentPage?.type, currentPage?.slug || currentPage?.cpt],
|
||||
queryFn: async () => {
|
||||
if (!selectedPage) return null;
|
||||
const endpoint = selectedPage.type === 'page'
|
||||
? `/pages/${selectedPage.slug}`
|
||||
: `/templates/${selectedPage.cpt}`;
|
||||
// api.get returns JSON directly
|
||||
if (!currentPage) return null;
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
const response = await api.get(endpoint);
|
||||
return response; // Return response directly
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedPage,
|
||||
enabled: !!currentPage,
|
||||
});
|
||||
|
||||
// Update local structure when page data loads
|
||||
// Update store when page data loads
|
||||
useEffect(() => {
|
||||
if (pageData?.structure) {
|
||||
setStructure(pageData.structure);
|
||||
setHasUnsavedChanges(false);
|
||||
if (pageData?.structure?.sections) {
|
||||
setSections(pageData.structure.sections);
|
||||
markAsSaved();
|
||||
}
|
||||
}, [pageData]);
|
||||
if (pageData?.available_sources) {
|
||||
setAvailableSources(pageData.available_sources);
|
||||
}
|
||||
// Sync isFrontPage if returned from single page API (optional, but good practice)
|
||||
if (pageData?.is_front_page !== undefined && currentPage) {
|
||||
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
|
||||
|
||||
// Update loading state
|
||||
useEffect(() => {
|
||||
setIsLoading(pageLoading);
|
||||
}, [pageLoading, setIsLoading]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedPage || !structure) return;
|
||||
const endpoint = selectedPage.type === 'page'
|
||||
? `/pages/${selectedPage.slug}`
|
||||
: `/templates/${selectedPage.cpt}`;
|
||||
return api.post(endpoint, { sections: structure.sections });
|
||||
if (!currentPage) return;
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
return api.post(endpoint, { sections });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Page saved successfully'));
|
||||
setHasUnsavedChanges(false);
|
||||
markAsSaved();
|
||||
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
|
||||
},
|
||||
onError: () => {
|
||||
@@ -99,166 +133,244 @@ export default function AppearancePages() {
|
||||
},
|
||||
});
|
||||
|
||||
// Handle section update
|
||||
const handleSectionUpdate = (updatedSection: Section) => {
|
||||
if (!structure) return;
|
||||
const newSections = structure.sections.map(s =>
|
||||
s.id === updatedSection.id ? updatedSection : s
|
||||
);
|
||||
setStructure({ ...structure, sections: newSections });
|
||||
setSelectedSection(updatedSection);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return api.del(`/pages/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Page deleted successfully'));
|
||||
markAsSaved(); // Clear unsaved flag
|
||||
setCurrentPage(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to delete page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Add new section
|
||||
const handleAddSection = (sectionType: string) => {
|
||||
if (!structure) {
|
||||
setStructure({
|
||||
type: selectedPage?.type || 'page',
|
||||
sections: [],
|
||||
// Set as SPA Landing mutation
|
||||
const setSpaLandingMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return api.post(`/pages/${id}/set-as-spa-landing`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Set as SPA Landing Page'));
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
// Update local state is handled by re-fetching pages or we can optimistic update
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, isSpaLanding: true });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to set SPA Landing Page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Unset SPA Landing mutation
|
||||
const unsetSpaLandingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return api.post(`/pages/unset-spa-landing`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Unset SPA Landing Page'));
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, isSpaLanding: false });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to unset SPA Landing Page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Handle page selection
|
||||
const handleSelectPage = (page: PageItem) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
||||
}
|
||||
if (page.type === 'page') {
|
||||
setCurrentPage({
|
||||
...page,
|
||||
isSpaLanding: !!(page as any).isSpaLanding
|
||||
});
|
||||
}
|
||||
const newSection: Section = {
|
||||
id: `section-${Date.now()}`,
|
||||
type: sectionType,
|
||||
layoutVariant: 'default',
|
||||
colorScheme: 'default',
|
||||
props: {},
|
||||
} else {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
setStructure(prev => ({
|
||||
...prev!,
|
||||
sections: [...(prev?.sections || []), newSection],
|
||||
}));
|
||||
setSelectedSection(newSection);
|
||||
setHasUnsavedChanges(true);
|
||||
setSelectedSection(null);
|
||||
};
|
||||
|
||||
// Delete section
|
||||
const handleDeleteSection = (sectionId: string) => {
|
||||
if (!structure) return;
|
||||
setStructure({
|
||||
...structure,
|
||||
sections: structure.sections.filter(s => s.id !== sectionId),
|
||||
});
|
||||
if (selectedSection?.id === sectionId) {
|
||||
setSelectedSection(null);
|
||||
const handleDiscard = () => {
|
||||
if (pageData?.structure?.sections) {
|
||||
setSections(pageData.structure.sections);
|
||||
markAsSaved();
|
||||
toast.success(__('Changes discarded'));
|
||||
}
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
// Move section
|
||||
const handleMoveSection = (sectionId: string, direction: 'up' | 'down') => {
|
||||
if (!structure) return;
|
||||
const index = structure.sections.findIndex(s => s.id === sectionId);
|
||||
if (index === -1) return;
|
||||
const handleDeletePage = () => {
|
||||
if (!currentPage || !currentPage.id) return;
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= structure.sections.length) return;
|
||||
|
||||
const newSections = [...structure.sections];
|
||||
[newSections[index], newSections[newIndex]] = [newSections[newIndex], newSections[index]];
|
||||
setStructure({ ...structure, sections: newSections });
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
// Reorder sections (drag-and-drop)
|
||||
const handleReorderSections = (newSections: Section[]) => {
|
||||
if (!structure) return;
|
||||
setStructure({ ...structure, sections: newSections });
|
||||
setHasUnsavedChanges(true);
|
||||
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
|
||||
deleteMutation.mutate(currentPage.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col">
|
||||
<div className={
|
||||
cn(
|
||||
"flex flex-col bg-white transition-all duration-300",
|
||||
isFullscreen ? "fixed inset-0 z-[100] h-screen" : "h-[calc(100vh-64px)]"
|
||||
)
|
||||
} >
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
< div className="flex items-center justify-between px-6 py-3 border-b bg-white" >
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedPage ? selectedPage.title : __('Select a page to edit')}
|
||||
{currentPage ? currentPage.title : __('Select a page to edit')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={isFullscreen ? __('Exit Fullscreen') : __('Enter Fullscreen')}
|
||||
className="mr-2"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||
<>
|
||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
{__('Discard')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Create Page')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!hasUnsavedChanges || saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? __('Saving...') : __('Save Changes')}
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saveMutation.isPending ? __('Saving...') : __('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* 3-Column Layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 3-Column Layout: Sidebar | Canvas | Inspector */}
|
||||
< div className="flex-1 flex overflow-hidden" >
|
||||
{/* Left Column: Pages List */}
|
||||
<PageSidebar
|
||||
< PageSidebar
|
||||
pages={pages}
|
||||
selectedPage={selectedPage}
|
||||
onSelectPage={(page) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
||||
}
|
||||
setSelectedPage(page);
|
||||
setSelectedSection(null);
|
||||
}}
|
||||
selectedPage={currentPage}
|
||||
onSelectPage={handleSelectPage}
|
||||
isLoading={pagesLoading}
|
||||
/>
|
||||
|
||||
{/* Center Column: Section Editor */}
|
||||
<div className="flex-1 bg-gray-50 overflow-y-auto p-6">
|
||||
{selectedPage ? (
|
||||
<SectionEditor
|
||||
sections={structure?.sections || []}
|
||||
selectedSection={selectedSection}
|
||||
{/* Center Column: Canvas Renderer */}
|
||||
{
|
||||
currentPage ? (
|
||||
<CanvasRenderer
|
||||
sections={sections}
|
||||
selectedSectionId={selectedSectionId}
|
||||
deviceMode={deviceMode}
|
||||
onSelectSection={setSelectedSection}
|
||||
onAddSection={handleAddSection}
|
||||
onDeleteSection={handleDeleteSection}
|
||||
onMoveSection={handleMoveSection}
|
||||
onReorderSections={handleReorderSections}
|
||||
isTemplate={selectedPage.type === 'template'}
|
||||
cpt={selectedPage.cpt}
|
||||
isLoading={pageLoading}
|
||||
onAddSection={addSection}
|
||||
onDeleteSection={deleteSection}
|
||||
onDuplicateSection={duplicateSection}
|
||||
onMoveSection={moveSection}
|
||||
onReorderSections={reorderSections}
|
||||
onDeviceModeChange={setDeviceMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-400">
|
||||
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<Layout className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>{__('Select a page from the sidebar to start editing')}</p>
|
||||
<Layout className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">{__('Select a page from the sidebar')}</p>
|
||||
<p className="text-sm mt-2">{__('Edit pages and templates visually')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Right Column: Settings & Preview */}
|
||||
<PageSettings
|
||||
page={selectedPage}
|
||||
section={selectedSection}
|
||||
sections={structure?.sections || []}
|
||||
onSectionUpdate={handleSectionUpdate}
|
||||
isTemplate={selectedPage?.type === 'template'}
|
||||
availableSources={pageData?.available_sources || []}
|
||||
/>
|
||||
</div>
|
||||
{/* Right Column: Inspector Panel */}
|
||||
{
|
||||
currentPage && (
|
||||
<InspectorPanel
|
||||
page={currentPage}
|
||||
selectedSection={selectedSection}
|
||||
isCollapsed={inspectorCollapsed}
|
||||
isTemplate={currentPage.type === 'template'}
|
||||
availableSources={availableSources}
|
||||
onToggleCollapse={() => setInspectorCollapsed(!inspectorCollapsed)}
|
||||
onSectionPropChange={(propName, value) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionProp(selectedSectionId, propName, value);
|
||||
}
|
||||
}}
|
||||
onLayoutChange={(layout) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionLayout(selectedSectionId, layout);
|
||||
}
|
||||
}}
|
||||
onColorSchemeChange={(scheme) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionColorScheme(selectedSectionId, scheme);
|
||||
}
|
||||
}}
|
||||
onSectionStylesChange={(styles) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionStyles(selectedSectionId, styles);
|
||||
}
|
||||
}}
|
||||
onElementStylesChange={(fieldName, styles) => {
|
||||
if (selectedSectionId) {
|
||||
updateElementStyles(selectedSectionId, fieldName, styles);
|
||||
}
|
||||
}}
|
||||
onDeleteSection={() => {
|
||||
if (selectedSectionId) {
|
||||
deleteSection(selectedSectionId);
|
||||
}
|
||||
}}
|
||||
onSetAsSpaLanding={() => {
|
||||
if (currentPage?.id) {
|
||||
setSpaLandingMutation.mutate(currentPage.id);
|
||||
}
|
||||
}}
|
||||
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||
onDeletePage={handleDeletePage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
|
||||
{/* Create Page Modal */}
|
||||
<CreatePageModal
|
||||
< CreatePageModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
onCreated={(newPage) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
setSelectedPage(newPage);
|
||||
}}
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -10,7 +10,8 @@ import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/
|
||||
import { CodeEditor } from '@/components/ui/code-editor';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ArrowLeft, Eye, Edit, RotateCcw, FileText } from 'lucide-react';
|
||||
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { markdownToHtml } from '@/lib/markdown-utils';
|
||||
@@ -38,12 +39,22 @@ export default function EditTemplate() {
|
||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||
const [activeTab, setActiveTab] = useState('preview');
|
||||
|
||||
// Fetch email customization settings
|
||||
// Send Test Email state
|
||||
const [testEmailDialogOpen, setTestEmailDialogOpen] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
|
||||
// Fetch email customization settings (for non-color settings like logo, footer, social links)
|
||||
const { data: emailSettings } = useQuery({
|
||||
queryKey: ['email-settings'],
|
||||
queryFn: () => api.get('/notifications/email-settings'),
|
||||
});
|
||||
|
||||
// Fetch appearance settings for unified colors
|
||||
const { data: appearanceSettings } = useQuery({
|
||||
queryKey: ['appearance-settings'],
|
||||
queryFn: () => api.get('/appearance/settings'),
|
||||
});
|
||||
|
||||
// Fetch template
|
||||
const { data: template, isLoading, error } = useQuery({
|
||||
queryKey: ['notification-template', eventId, channelId, recipientType],
|
||||
@@ -114,6 +125,32 @@ export default function EditTemplate() {
|
||||
}
|
||||
};
|
||||
|
||||
// Send test email mutation
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
return api.post(`/notifications/templates/${eventId}/${channelId}/send-test`, {
|
||||
email,
|
||||
recipient: recipientType,
|
||||
});
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
toast.success(data.message || __('Test email sent successfully'));
|
||||
setTestEmailDialogOpen(false);
|
||||
setTestEmail('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to send test email'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendTest = () => {
|
||||
if (!testEmail || !testEmail.includes('@')) {
|
||||
toast.error(__('Please enter a valid email address'));
|
||||
return;
|
||||
}
|
||||
sendTestMutation.mutate(testEmail);
|
||||
};
|
||||
|
||||
// Visual mode: Update blocks → Markdown (source of truth)
|
||||
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
|
||||
setBlocks(newBlocks);
|
||||
@@ -288,14 +325,15 @@ export default function EditTemplate() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get email settings for preview
|
||||
// Get email settings for preview - use UNIFIED appearance settings for colors
|
||||
const settings = emailSettings || {};
|
||||
const primaryColor = settings.primary_color || '#7f54b3';
|
||||
const secondaryColor = settings.secondary_color || '#7f54b3';
|
||||
const heroGradientStart = settings.hero_gradient_start || '#667eea';
|
||||
const heroGradientEnd = settings.hero_gradient_end || '#764ba2';
|
||||
const heroTextColor = settings.hero_text_color || '#ffffff';
|
||||
const buttonTextColor = settings.button_text_color || '#ffffff';
|
||||
const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
|
||||
const primaryColor = appearColors.primary || '#7f54b3';
|
||||
const secondaryColor = appearColors.secondary || '#7f54b3';
|
||||
const heroGradientStart = appearColors.gradientStart || '#667eea';
|
||||
const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
|
||||
const heroTextColor = '#ffffff'; // Always white on gradient
|
||||
const buttonTextColor = '#ffffff'; // Always white on primary
|
||||
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
|
||||
const socialIconColor = settings.social_icon_color || 'white';
|
||||
const logoUrl = settings.logo_url || '';
|
||||
@@ -307,10 +345,11 @@ export default function EditTemplate() {
|
||||
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
||||
|
||||
// Generate social icons HTML with PNG images
|
||||
// Get plugin URL from config, with fallback
|
||||
const pluginUrl =
|
||||
(window as any).woonoowData?.pluginUrl ||
|
||||
(window as any).WNW_CONFIG?.pluginUrl ||
|
||||
'';
|
||||
'/wp-content/plugins/woonoow/';
|
||||
const socialIconsHtml = socialLinks.length > 0 ? `
|
||||
<div style="margin-top: 16px;">
|
||||
${socialLinks.map((link: any) => `
|
||||
@@ -414,128 +453,175 @@ export default function EditTemplate() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={template.event_label || __('Edit Template')}
|
||||
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
||||
onSave={handleSave}
|
||||
saveLabel={__('Save Template')}
|
||||
isLoading={false}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Determine if staff or customer based on event category
|
||||
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
||||
const page = isStaffEvent ? 'staff' : 'customer';
|
||||
navigate(`/settings/notifications/${page}?tab=events`);
|
||||
}}
|
||||
className="gap-2"
|
||||
title={__('Back')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Back')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
title={__('Reset to Default')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
<>
|
||||
<SettingsLayout
|
||||
title={template.event_label || __('Edit Template')}
|
||||
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
||||
onSave={handleSave}
|
||||
saveLabel={__('Save Template')}
|
||||
isLoading={false}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Determine if staff or customer based on event category
|
||||
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
||||
const page = isStaffEvent ? 'staff' : 'customer';
|
||||
navigate(`/settings/notifications/${page}?tab=events`);
|
||||
}}
|
||||
className="gap-2"
|
||||
title={__('Back')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Back')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
title={__('Reset to Default')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setTestEmailDialogOpen(true)}
|
||||
className="gap-2"
|
||||
title={__('Send Test')}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Send Test')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||
<FileText className="h-3 w-3" />
|
||||
{__('Markdown')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||
<FileText className="h-3 w-3" />
|
||||
{__('Markdown')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
|
||||
{/* Send Test Email Dialog */}
|
||||
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Send a test email with sample data to verify the template looks correct.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||
<Input
|
||||
id="test-email"
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('The subject will be prefixed with [TEST]')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSendTest} disabled={sendTestMutation.isPending}>
|
||||
{sendTestMutation.isPending ? __('Sending...') : __('Send Test')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,190 +219,22 @@ export default function EmailCustomization() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Brand Colors */}
|
||||
{/* Unified Colors Notice */}
|
||||
<SettingsCard
|
||||
title={__('Brand Colors')}
|
||||
description={__('Set your primary and secondary brand colors for buttons and accents')}
|
||||
description={__('Colors for buttons, gradients, and accents in emails')}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="primary_color">{__('Primary Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="primary_color"
|
||||
type="color"
|
||||
value={formData.primary_color}
|
||||
onChange={(e) => handleChange('primary_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.primary_color}
|
||||
onChange={(e) => handleChange('primary_color', e.target.value)}
|
||||
placeholder="#7f54b3"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Used for primary buttons and main accents')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secondary_color">{__('Secondary Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="secondary_color"
|
||||
type="color"
|
||||
value={formData.secondary_color}
|
||||
onChange={(e) => handleChange('secondary_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.secondary_color}
|
||||
onChange={(e) => handleChange('secondary_color', e.target.value)}
|
||||
placeholder="#7f54b3"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Used for outline buttons and borders')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Hero Card Gradient */}
|
||||
<SettingsCard
|
||||
title={__('Hero Card Gradient')}
|
||||
description={__('Customize the gradient colors for hero/success card backgrounds')}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hero_gradient_start">{__('Gradient Start')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="hero_gradient_start"
|
||||
type="color"
|
||||
value={formData.hero_gradient_start}
|
||||
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.hero_gradient_start}
|
||||
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
|
||||
placeholder="#667eea"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hero_gradient_end">{__('Gradient End')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="hero_gradient_end"
|
||||
type="color"
|
||||
value={formData.hero_gradient_end}
|
||||
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.hero_gradient_end}
|
||||
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
|
||||
placeholder="#764ba2"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hero_text_color">{__('Text Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="hero_text_color"
|
||||
type="color"
|
||||
value={formData.hero_text_color}
|
||||
onChange={(e) => handleChange('hero_text_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.hero_text_color}
|
||||
onChange={(e) => handleChange('hero_text_color', e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Text and heading color for hero cards (usually white)')}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||
<strong>{__('Colors are now unified!')}</strong>{' '}
|
||||
{__('Email colors (buttons, gradients) now use the same colors as your storefront for consistent branding.')}
|
||||
</p>
|
||||
<p className="text-sm text-blue-900 dark:text-blue-100 mt-2">
|
||||
{__('To change colors, go to')}{' '}
|
||||
<a href="#/appearance/general" className="font-medium underline hover:no-underline">
|
||||
{__('Appearance → General → Colors')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="mt-4 p-6 rounded-lg text-center" style={{
|
||||
background: `linear-gradient(135deg, ${formData.hero_gradient_start} 0%, ${formData.hero_gradient_end} 100%)`
|
||||
}}>
|
||||
<h3 className="text-xl font-bold mb-2" style={{ color: formData.hero_text_color }}>{__('Preview')}</h3>
|
||||
<p className="text-sm opacity-90" style={{ color: formData.hero_text_color }}>{__('This is how your hero cards will look')}</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Button Styling */}
|
||||
<SettingsCard
|
||||
title={__('Button Styling')}
|
||||
description={__('Customize button text color and appearance')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button_text_color">{__('Button Text Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="button_text_color"
|
||||
type="color"
|
||||
value={formData.button_text_color}
|
||||
onChange={(e) => handleChange('button_text_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.button_text_color}
|
||||
onChange={(e) => handleChange('button_text_color', e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Text color for buttons (usually white for dark buttons)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Button Preview */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<button
|
||||
className="px-6 py-3 rounded-lg font-medium"
|
||||
style={{
|
||||
backgroundColor: formData.primary_color,
|
||||
color: formData.button_text_color,
|
||||
}}
|
||||
>
|
||||
{__('Primary Button')}
|
||||
</button>
|
||||
<button
|
||||
className="px-6 py-3 rounded-lg font-medium border-2"
|
||||
style={{
|
||||
borderColor: formData.secondary_color,
|
||||
color: formData.secondary_color,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{__('Secondary Button')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -540,7 +372,7 @@ export default function EmailCustomization() {
|
||||
{__('Add Social Link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{formData.social_links.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('No social links added. Click "Add Social Link" to get started.')}
|
||||
|
||||
@@ -22,5 +22,5 @@ module.exports = {
|
||||
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")]
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
|
||||
};
|
||||
Reference in New Issue
Block a user