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"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
@@ -2898,6 +2899,33 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/typography": {
|
||||||
|
"version": "0.5.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||||
|
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postcss-selector-parser": "6.0.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||||
|
"version": "6.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||||
|
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cssesc": "^3.0.0",
|
||||||
|
"util-deprecate": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.5",
|
"version": "5.90.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ import AppearanceCart from '@/routes/Appearance/Cart';
|
|||||||
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||||
|
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
|
||||||
import AppearancePages from '@/routes/Appearance/Pages';
|
import AppearancePages from '@/routes/Appearance/Pages';
|
||||||
import MarketingIndex from '@/routes/Marketing';
|
import MarketingIndex from '@/routes/Marketing';
|
||||||
import Newsletter from '@/routes/Marketing/Newsletter';
|
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||||
@@ -628,6 +629,7 @@ function AppRoutes() {
|
|||||||
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
||||||
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
||||||
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
||||||
|
<Route path="/appearance/menus" element={<AppearanceMenus />} />
|
||||||
<Route path="/appearance/pages" element={<AppearancePages />} />
|
<Route path="/appearance/pages" element={<AppearancePages />} />
|
||||||
|
|
||||||
{/* Marketing */}
|
{/* Marketing */}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function BlockRenderer({
|
|||||||
marginBottom: '24px'
|
marginBottom: '24px'
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '32px 40px',
|
padding: '32px 40px',
|
||||||
@@ -99,24 +99,24 @@ export function BlockRenderer({
|
|||||||
case 'button': {
|
case 'button': {
|
||||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||||
? {
|
? {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
background: '#7f54b3',
|
background: 'var(--wn-primary, #7f54b3)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
padding: '14px 28px',
|
padding: '14px 28px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: '#7f54b3',
|
color: 'var(--wn-secondary, #7f54b3)',
|
||||||
padding: '12px 26px',
|
padding: '12px 26px',
|
||||||
border: '2px solid #7f54b3',
|
border: '2px solid var(--wn-secondary, #7f54b3)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
textAlign: block.align || 'center',
|
textAlign: block.align || 'center',
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
if (block.type === 'card') {
|
if (block.type === 'card') {
|
||||||
// Convert markdown to HTML for rich text editor
|
// Convert markdown to HTML for rich text editor
|
||||||
const htmlContent = parseMarkdownBasics(block.content);
|
const htmlContent = parseMarkdownBasics(block.content);
|
||||||
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
|
||||||
setEditingContent(htmlContent);
|
setEditingContent(htmlContent);
|
||||||
setEditingCardType(block.cardType);
|
setEditingCardType(block.cardType);
|
||||||
} else if (block.type === 'button') {
|
} else if (block.type === 'button') {
|
||||||
|
|||||||
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.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
||||||
|
ButtonExtension,
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
@@ -65,7 +67,6 @@ export function RichTextEditor({
|
|||||||
class: 'max-w-full h-auto rounded',
|
class: 'max-w-full h-auto rounded',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ButtonExtension,
|
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface Option {
|
|||||||
/** What to render in the button/list. Can be a string or React node. */
|
/** What to render in the button/list. Can be a string or React node. */
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
/** Optional text used for filtering. Falls back to string label or value. */
|
/** Optional text used for filtering. Falls back to string label or value. */
|
||||||
searchText?: string;
|
triggerLabel?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -55,7 +55,7 @@ export function SearchableSelect({
|
|||||||
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}>
|
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -65,7 +65,7 @@ export function SearchableSelect({
|
|||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
tabIndex={disabled ? -1 : 0}
|
tabIndex={disabled ? -1 : 0}
|
||||||
>
|
>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
|
||||||
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
"relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'a[data-button]',
|
tag: 'a[data-button]',
|
||||||
|
priority: 100, // Higher priority than Link extension (default 50)
|
||||||
getAttrs: (node: HTMLElement) => ({
|
getAttrs: (node: HTMLElement) => ({
|
||||||
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||||
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||||
@@ -47,6 +48,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button',
|
tag: 'a.button',
|
||||||
|
priority: 100,
|
||||||
getAttrs: (node: HTMLElement) => ({
|
getAttrs: (node: HTMLElement) => ({
|
||||||
text: node.textContent || 'Click Here',
|
text: node.textContent || 'Click Here',
|
||||||
href: node.getAttribute('href') || '#',
|
href: node.getAttribute('href') || '#',
|
||||||
@@ -55,6 +57,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button-outline',
|
tag: 'a.button-outline',
|
||||||
|
priority: 100,
|
||||||
getAttrs: (node: HTMLElement) => ({
|
getAttrs: (node: HTMLElement) => ({
|
||||||
text: node.textContent || 'Click Here',
|
text: node.textContent || 'Click Here',
|
||||||
href: node.getAttribute('href') || '#',
|
href: node.getAttribute('href') || '#',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, ReactNode } from 'react';
|
import React, { createContext, useContext, ReactNode, useEffect } from 'react';
|
||||||
|
|
||||||
interface AppContextType {
|
interface AppContextType {
|
||||||
isStandalone: boolean;
|
isStandalone: boolean;
|
||||||
@@ -16,6 +16,35 @@ export function AppProvider({
|
|||||||
isStandalone: boolean;
|
isStandalone: boolean;
|
||||||
exitFullscreen?: () => void;
|
exitFullscreen?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch and apply appearance settings (colors)
|
||||||
|
const loadAppearance = async () => {
|
||||||
|
try {
|
||||||
|
const restUrl = (window as any).WNW_CONFIG?.restUrl || '';
|
||||||
|
const response = await fetch(`${restUrl}/appearance/settings`);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
// API returns { success: true, data: { general: { colors: {...} } } }
|
||||||
|
const colors = result.data?.general?.colors;
|
||||||
|
if (colors) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
// Inject all color settings as CSS variables
|
||||||
|
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
|
||||||
|
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
|
||||||
|
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
|
||||||
|
if (colors.text) root.style.setProperty('--wn-text', colors.text);
|
||||||
|
if (colors.background) root.style.setProperty('--wn-background', colors.background);
|
||||||
|
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
|
||||||
|
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load appearance settings', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAppearance();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
|
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -68,8 +68,23 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
}).join('\n') + '\n\n';
|
}).join('\n') + '\n\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paragraphs - convert to double newlines
|
// Paragraphs - preserve text-align by using placeholders
|
||||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
|
const alignedParagraphs: { [key: string]: string } = {};
|
||||||
|
let alignIndex = 0;
|
||||||
|
markdown = markdown.replace(/<p([^>]*)>(.*?)<\/p>/gis, (match, attrs, content) => {
|
||||||
|
// Check for text-align in style attribute
|
||||||
|
const alignMatch = attrs.match(/text-align:\s*(center|right)/i);
|
||||||
|
if (alignMatch) {
|
||||||
|
const align = alignMatch[1].toLowerCase();
|
||||||
|
// Use double-bracket placeholder that won't be matched by HTML regex
|
||||||
|
const placeholder = `[[ALIGN${alignIndex}]]`;
|
||||||
|
alignedParagraphs[placeholder] = `<p style="text-align: ${align};">${content}</p>`;
|
||||||
|
alignIndex++;
|
||||||
|
return placeholder + '\n\n';
|
||||||
|
}
|
||||||
|
// No alignment, convert to plain text
|
||||||
|
return `${content}\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
// Line breaks
|
// Line breaks
|
||||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||||
@@ -80,6 +95,11 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
// Remove remaining HTML tags
|
// Remove remaining HTML tags
|
||||||
markdown = markdown.replace(/<[^>]+>/g, '');
|
markdown = markdown.replace(/<[^>]+>/g, '');
|
||||||
|
|
||||||
|
// Restore aligned paragraphs
|
||||||
|
Object.entries(alignedParagraphs).forEach(([placeholder, html]) => {
|
||||||
|
markdown = markdown.replace(placeholder, html);
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up excessive newlines
|
// Clean up excessive newlines
|
||||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export default function AppearanceGeneral() {
|
|||||||
accent: '#3b82f6',
|
accent: '#3b82f6',
|
||||||
text: '#111827',
|
text: '#111827',
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
|
gradientStart: '#9333ea', // purple-600 defaults
|
||||||
|
gradientEnd: '#3b82f6', // blue-500 defaults
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,6 +72,8 @@ export default function AppearanceGeneral() {
|
|||||||
accent: general.colors.accent || '#3b82f6',
|
accent: general.colors.accent || '#3b82f6',
|
||||||
text: general.colors.text || '#111827',
|
text: general.colors.text || '#111827',
|
||||||
background: general.colors.background || '#ffffff',
|
background: general.colors.background || '#ffffff',
|
||||||
|
gradientStart: general.colors.gradientStart || '#9333ea',
|
||||||
|
gradientEnd: general.colors.gradientEnd || '#3b82f6',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,18 +349,18 @@ export default function AppearanceGeneral() {
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{Object.entries(colors).map(([key, value]) => (
|
{Object.entries(colors).map(([key, value]) => (
|
||||||
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1)} htmlFor={`color-${key}`}>
|
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')} htmlFor={`color-${key}`}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id={`color-${key}`}
|
id={`color-${key}`}
|
||||||
type="color"
|
type="color"
|
||||||
value={value}
|
value={value as string}
|
||||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||||
className="w-20 h-10 cursor-pointer"
|
className="w-20 h-10 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value as string}
|
||||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||||
className="flex-1 font-mono"
|
className="flex-1 font-mono"
|
||||||
/>
|
/>
|
||||||
|
|||||||
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 React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -35,6 +35,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
const [pageType, setPageType] = useState<'page' | 'template'>('page');
|
const [pageType, setPageType] = useState<'page' | 'template'>('page');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
|
||||||
|
|
||||||
// Prevent double submission
|
// Prevent double submission
|
||||||
const isSubmittingRef = useRef(false);
|
const isSubmittingRef = useRef(false);
|
||||||
@@ -42,9 +43,18 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
// Get site URL from WordPress config
|
// Get site URL from WordPress config
|
||||||
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
|
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
|
||||||
|
|
||||||
|
// Fetch templates
|
||||||
|
const { data: templates = [] } = useQuery({
|
||||||
|
queryKey: ['templates-presets'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get('/templates/presets');
|
||||||
|
return res as { id: string; label: string; description: string; icon: string }[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Create page mutation
|
// Create page mutation
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: { title: string; slug: string }) => {
|
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
|
||||||
// Guard against double submission
|
// Guard against double submission
|
||||||
if (isSubmittingRef.current) {
|
if (isSubmittingRef.current) {
|
||||||
throw new Error('Request already in progress');
|
throw new Error('Request already in progress');
|
||||||
@@ -53,7 +63,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// api.post returns JSON directly (not wrapped in { data: ... })
|
// api.post returns JSON directly (not wrapped in { data: ... })
|
||||||
const response = await api.post('/pages', { title: data.title, slug: data.slug });
|
const response = await api.post('/pages', {
|
||||||
|
title: data.title,
|
||||||
|
slug: data.slug,
|
||||||
|
templateId: data.templateId
|
||||||
|
});
|
||||||
return response; // Return response directly, not response.data
|
return response; // Return response directly, not response.data
|
||||||
} finally {
|
} finally {
|
||||||
// Reset after a delay to prevent race conditions
|
// Reset after a delay to prevent race conditions
|
||||||
@@ -74,6 +88,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setSlug('');
|
setSlug('');
|
||||||
|
setSelectedTemplateId('blank');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -105,7 +120,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pageType === 'page' && title && slug) {
|
if (pageType === 'page' && title && slug) {
|
||||||
createMutation.mutate({ title, slug });
|
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +130,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setSlug('');
|
setSlug('');
|
||||||
setPageType('page');
|
setPageType('page');
|
||||||
|
setSelectedTemplateId('blank');
|
||||||
isSubmittingRef.current = false;
|
isSubmittingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -123,7 +139,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{__('Create New Page')}</DialogTitle>
|
<DialogTitle>{__('Create New Page')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -133,8 +149,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
|
|
||||||
<div className="space-y-6 py-4 px-1">
|
<div className="space-y-6 py-4 px-1">
|
||||||
{/* Page Type Selection */}
|
{/* Page Type Selection */}
|
||||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')}>
|
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
|
||||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer hover:bg-accent/50 transition-colors">
|
<div
|
||||||
|
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
|
||||||
|
onClick={() => setPageType('page')}
|
||||||
|
>
|
||||||
<RadioGroupItem value="page" id="page" className="mt-1" />
|
<RadioGroupItem value="page" id="page" className="mt-1" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
|
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||||
@@ -147,7 +166,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50">
|
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
|
||||||
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
|
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
|
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||||
@@ -163,30 +182,59 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
|||||||
|
|
||||||
{/* Page Details */}
|
{/* Page Details */}
|
||||||
{pageType === 'page' && (
|
{pageType === 'page' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||||
id="title"
|
<Input
|
||||||
value={title}
|
id="title"
|
||||||
onChange={(e) => handleTitleChange(e.target.value)}
|
value={title}
|
||||||
placeholder={__('e.g., About Us')}
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
disabled={createMutation.isPending}
|
placeholder={__('e.g., About Us')}
|
||||||
/>
|
disabled={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||||
|
placeholder={__('e.g., about-us')}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
<Label>{__('Choose a Template')}</Label>
|
||||||
<Input
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
id="slug"
|
{templates.map((tpl) => (
|
||||||
value={slug}
|
<div
|
||||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
key={tpl.id}
|
||||||
placeholder={__('e.g., about-us')}
|
className={`
|
||||||
disabled={createMutation.isPending}
|
relative p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||||
/>
|
${selectedTemplateId === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||||
<p className="text-xs text-muted-foreground">
|
`}
|
||||||
{__('URL will be: ')}<span className="font-mono text-primary">{siteUrl}/{slug || 'page-slug'}</span>
|
onClick={() => setSelectedTemplateId(tpl.id)}
|
||||||
</p>
|
>
|
||||||
|
<div className="mb-2 font-medium text-sm flex items-center gap-2">
|
||||||
|
{tpl.label}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{tpl.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{templates.length === 0 && (
|
||||||
|
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
|
||||||
|
{__('Loading templates...')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 React from 'react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FileText, Layout, Loader2 } from 'lucide-react';
|
import { FileText, Layout, Loader2, Home } from 'lucide-react';
|
||||||
|
|
||||||
interface PageItem {
|
interface PageItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -51,14 +51,19 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
|
|||||||
key={`page-${page.id}`}
|
key={`page-${page.id}`}
|
||||||
onClick={() => onSelectPage(page)}
|
onClick={() => onSelectPage(page)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between group',
|
||||||
'hover:bg-gray-100',
|
'hover:bg-gray-100',
|
||||||
selectedPage?.id === page.id && selectedPage?.type === 'page'
|
selectedPage?.id === page.id && selectedPage?.type === 'page'
|
||||||
? 'bg-primary/10 text-primary font-medium'
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
: 'text-gray-700'
|
: 'text-gray-700'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{page.title}
|
<span className="truncate">{page.title}</span>
|
||||||
|
{(page as any).isSpaLanding && (
|
||||||
|
<span title="SPA Landing Page" className="flex-shrink-0 ml-2">
|
||||||
|
<Home className="w-3.5 h-3.5 text-green-600" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
interface Section {
|
interface Section {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -158,18 +169,36 @@ function SortableSectionCard({
|
|||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<AlertDialog>
|
||||||
variant="ghost"
|
<AlertDialogTrigger asChild>
|
||||||
size="icon"
|
<button
|
||||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
|
||||||
onClick={() => {
|
title="Delete"
|
||||||
if (confirm(__('Delete this section?'))) {
|
>
|
||||||
onDelete();
|
<Trash2 className="w-4 h-4" />
|
||||||
}
|
</button>
|
||||||
}}
|
</AlertDialogTrigger>
|
||||||
>
|
<AlertDialogContent className="z-[60]">
|
||||||
<Trash2 className="w-4 h-4" />
|
<AlertDialogHeader>
|
||||||
</Button>
|
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{__('Delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
import { Plus, FileText, Layout, Trash2, Eye, Settings } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { PageSidebar } from './components/PageSidebar';
|
import { PageSidebar } from './components/PageSidebar';
|
||||||
import { SectionEditor } from './components/SectionEditor';
|
import { CanvasRenderer } from './components/CanvasRenderer';
|
||||||
import { PageSettings } from './components/PageSettings';
|
import { InspectorPanel } from './components/InspectorPanel';
|
||||||
import { CreatePageModal } from './components/CreatePageModal';
|
import { CreatePageModal } from './components/CreatePageModal';
|
||||||
|
import { usePageEditorStore, Section } from './store/usePageEditorStore';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface PageItem {
|
interface PageItem {
|
||||||
@@ -23,75 +23,109 @@ interface PageItem {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
has_template?: boolean;
|
has_template?: boolean;
|
||||||
permalink_base?: string;
|
permalink_base?: string;
|
||||||
}
|
isFrontPage?: boolean;
|
||||||
|
|
||||||
interface Section {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
layoutVariant?: string;
|
|
||||||
colorScheme?: string;
|
|
||||||
props: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageStructure {
|
|
||||||
type: 'page' | 'template';
|
|
||||||
sections: Section[];
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppearancePages() {
|
export default function AppearancePages() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedPage, setSelectedPage] = useState<PageItem | null>(null);
|
|
||||||
const [selectedSection, setSelectedSection] = useState<Section | null>(null);
|
|
||||||
const [structure, setStructure] = useState<PageStructure | null>(null);
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// Zustand store
|
||||||
|
const {
|
||||||
|
currentPage,
|
||||||
|
sections,
|
||||||
|
selectedSectionId,
|
||||||
|
deviceMode,
|
||||||
|
inspectorCollapsed,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isLoading,
|
||||||
|
availableSources,
|
||||||
|
setCurrentPage,
|
||||||
|
setSections,
|
||||||
|
setSelectedSection,
|
||||||
|
setDeviceMode,
|
||||||
|
setInspectorCollapsed,
|
||||||
|
setAvailableSources,
|
||||||
|
setIsLoading,
|
||||||
|
addSection,
|
||||||
|
deleteSection,
|
||||||
|
duplicateSection,
|
||||||
|
moveSection,
|
||||||
|
reorderSections,
|
||||||
|
updateSectionProp,
|
||||||
|
updateSectionLayout,
|
||||||
|
updateSectionColorScheme,
|
||||||
|
updateSectionStyles,
|
||||||
|
updateElementStyles,
|
||||||
|
markAsSaved,
|
||||||
|
setAsSpaLanding,
|
||||||
|
unsetSpaLanding,
|
||||||
|
} = usePageEditorStore();
|
||||||
|
|
||||||
|
// Get selected section object
|
||||||
|
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
|
||||||
|
|
||||||
// Fetch all pages and templates
|
// Fetch all pages and templates
|
||||||
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
||||||
queryKey: ['pages'],
|
queryKey: ['pages'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// api.get returns JSON directly (not wrapped in { data: ... })
|
|
||||||
const response = await api.get('/pages');
|
const response = await api.get('/pages');
|
||||||
return response; // Return response directly
|
// Map API snake_case to frontend camelCase
|
||||||
|
return response.map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
isSpaLanding: !!p.is_spa_frontpage
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch selected page/template structure
|
// Fetch selected page/template structure
|
||||||
const { data: pageData, isLoading: pageLoading } = useQuery({
|
const { data: pageData, isLoading: pageLoading } = useQuery({
|
||||||
queryKey: ['page-structure', selectedPage?.type, selectedPage?.slug || selectedPage?.cpt],
|
queryKey: ['page-structure', currentPage?.type, currentPage?.slug || currentPage?.cpt],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedPage) return null;
|
if (!currentPage) return null;
|
||||||
const endpoint = selectedPage.type === 'page'
|
const endpoint = currentPage.type === 'page'
|
||||||
? `/pages/${selectedPage.slug}`
|
? `/pages/${currentPage.slug}`
|
||||||
: `/templates/${selectedPage.cpt}`;
|
: `/templates/${currentPage.cpt}`;
|
||||||
// api.get returns JSON directly
|
|
||||||
const response = await api.get(endpoint);
|
const response = await api.get(endpoint);
|
||||||
return response; // Return response directly
|
return response;
|
||||||
},
|
},
|
||||||
enabled: !!selectedPage,
|
enabled: !!currentPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update local structure when page data loads
|
// Update store when page data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageData?.structure) {
|
if (pageData?.structure?.sections) {
|
||||||
setStructure(pageData.structure);
|
setSections(pageData.structure.sections);
|
||||||
setHasUnsavedChanges(false);
|
markAsSaved();
|
||||||
}
|
}
|
||||||
}, [pageData]);
|
if (pageData?.available_sources) {
|
||||||
|
setAvailableSources(pageData.available_sources);
|
||||||
|
}
|
||||||
|
// Sync isFrontPage if returned from single page API (optional, but good practice)
|
||||||
|
if (pageData?.is_front_page !== undefined && currentPage) {
|
||||||
|
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(pageLoading);
|
||||||
|
}, [pageLoading, setIsLoading]);
|
||||||
|
|
||||||
// Save mutation
|
// Save mutation
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!selectedPage || !structure) return;
|
if (!currentPage) return;
|
||||||
const endpoint = selectedPage.type === 'page'
|
const endpoint = currentPage.type === 'page'
|
||||||
? `/pages/${selectedPage.slug}`
|
? `/pages/${currentPage.slug}`
|
||||||
: `/templates/${selectedPage.cpt}`;
|
: `/templates/${currentPage.cpt}`;
|
||||||
return api.post(endpoint, { sections: structure.sections });
|
return api.post(endpoint, { sections });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(__('Page saved successfully'));
|
toast.success(__('Page saved successfully'));
|
||||||
setHasUnsavedChanges(false);
|
markAsSaved();
|
||||||
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
|
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -99,166 +133,244 @@ export default function AppearancePages() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle section update
|
// Delete mutation
|
||||||
const handleSectionUpdate = (updatedSection: Section) => {
|
const deleteMutation = useMutation({
|
||||||
if (!structure) return;
|
mutationFn: async (id: number) => {
|
||||||
const newSections = structure.sections.map(s =>
|
return api.del(`/pages/${id}`);
|
||||||
s.id === updatedSection.id ? updatedSection : s
|
},
|
||||||
);
|
onSuccess: () => {
|
||||||
setStructure({ ...structure, sections: newSections });
|
toast.success(__('Page deleted successfully'));
|
||||||
setSelectedSection(updatedSection);
|
markAsSaved(); // Clear unsaved flag
|
||||||
setHasUnsavedChanges(true);
|
setCurrentPage(null);
|
||||||
};
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to delete page'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Add new section
|
// Set as SPA Landing mutation
|
||||||
const handleAddSection = (sectionType: string) => {
|
const setSpaLandingMutation = useMutation({
|
||||||
if (!structure) {
|
mutationFn: async (id: number) => {
|
||||||
setStructure({
|
return api.post(`/pages/${id}/set-as-spa-landing`);
|
||||||
type: selectedPage?.type || 'page',
|
},
|
||||||
sections: [],
|
onSuccess: () => {
|
||||||
|
toast.success(__('Set as SPA Landing Page'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
// Update local state is handled by re-fetching pages or we can optimistic update
|
||||||
|
if (currentPage) {
|
||||||
|
setCurrentPage({ ...currentPage, isSpaLanding: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to set SPA Landing Page'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unset SPA Landing mutation
|
||||||
|
const unsetSpaLandingMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
return api.post(`/pages/unset-spa-landing`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Unset SPA Landing Page'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
if (currentPage) {
|
||||||
|
setCurrentPage({ ...currentPage, isSpaLanding: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to unset SPA Landing Page'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle page selection
|
||||||
|
const handleSelectPage = (page: PageItem) => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
||||||
|
}
|
||||||
|
if (page.type === 'page') {
|
||||||
|
setCurrentPage({
|
||||||
|
...page,
|
||||||
|
isSpaLanding: !!(page as any).isSpaLanding
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
const newSection: Section = {
|
setCurrentPage(page);
|
||||||
id: `section-${Date.now()}`,
|
|
||||||
type: sectionType,
|
|
||||||
layoutVariant: 'default',
|
|
||||||
colorScheme: 'default',
|
|
||||||
props: {},
|
|
||||||
};
|
};
|
||||||
setStructure(prev => ({
|
setSelectedSection(null);
|
||||||
...prev!,
|
|
||||||
sections: [...(prev?.sections || []), newSection],
|
|
||||||
}));
|
|
||||||
setSelectedSection(newSection);
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete section
|
const handleDiscard = () => {
|
||||||
const handleDeleteSection = (sectionId: string) => {
|
if (pageData?.structure?.sections) {
|
||||||
if (!structure) return;
|
setSections(pageData.structure.sections);
|
||||||
setStructure({
|
markAsSaved();
|
||||||
...structure,
|
toast.success(__('Changes discarded'));
|
||||||
sections: structure.sections.filter(s => s.id !== sectionId),
|
|
||||||
});
|
|
||||||
if (selectedSection?.id === sectionId) {
|
|
||||||
setSelectedSection(null);
|
|
||||||
}
|
}
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move section
|
const handleDeletePage = () => {
|
||||||
const handleMoveSection = (sectionId: string, direction: 'up' | 'down') => {
|
if (!currentPage || !currentPage.id) return;
|
||||||
if (!structure) return;
|
|
||||||
const index = structure.sections.findIndex(s => s.id === sectionId);
|
|
||||||
if (index === -1) return;
|
|
||||||
|
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
|
||||||
if (newIndex < 0 || newIndex >= structure.sections.length) return;
|
deleteMutation.mutate(currentPage.id);
|
||||||
|
}
|
||||||
const newSections = [...structure.sections];
|
|
||||||
[newSections[index], newSections[newIndex]] = [newSections[newIndex], newSections[index]];
|
|
||||||
setStructure({ ...structure, sections: newSections });
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reorder sections (drag-and-drop)
|
|
||||||
const handleReorderSections = (newSections: Section[]) => {
|
|
||||||
if (!structure) return;
|
|
||||||
setStructure({ ...structure, sections: newSections });
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-64px)] flex flex-col">
|
<div className={
|
||||||
|
cn(
|
||||||
|
"flex flex-col bg-white transition-all duration-300",
|
||||||
|
isFullscreen ? "fixed inset-0 z-[100] h-screen" : "h-[calc(100vh-64px)]"
|
||||||
|
)
|
||||||
|
} >
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
|
< div className="flex items-center justify-between px-6 py-3 border-b bg-white" >
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
|
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{selectedPage ? selectedPage.title : __('Select a page to edit')}
|
{currentPage ? currentPage.title : __('Select a page to edit')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
title={isFullscreen ? __('Exit Fullscreen') : __('Enter Fullscreen')}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
<>
|
||||||
|
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDiscard}
|
||||||
|
>
|
||||||
|
<Undo2 className="w-4 h-4 mr-2" />
|
||||||
|
{__('Discard')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
{__('Create Page')}
|
{__('Create Page')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={!hasUnsavedChanges || saveMutation.isPending}
|
disabled={!hasUnsavedChanges || saveMutation.isPending}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? __('Saving...') : __('Save Changes')}
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{saveMutation.isPending ? __('Saving...') : __('Save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div >
|
||||||
|
|
||||||
{/* 3-Column Layout */}
|
{/* 3-Column Layout: Sidebar | Canvas | Inspector */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
< div className="flex-1 flex overflow-hidden" >
|
||||||
{/* Left Column: Pages List */}
|
{/* Left Column: Pages List */}
|
||||||
<PageSidebar
|
< PageSidebar
|
||||||
pages={pages}
|
pages={pages}
|
||||||
selectedPage={selectedPage}
|
selectedPage={currentPage}
|
||||||
onSelectPage={(page) => {
|
onSelectPage={handleSelectPage}
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
|
||||||
}
|
|
||||||
setSelectedPage(page);
|
|
||||||
setSelectedSection(null);
|
|
||||||
}}
|
|
||||||
isLoading={pagesLoading}
|
isLoading={pagesLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Center Column: Section Editor */}
|
{/* Center Column: Canvas Renderer */}
|
||||||
<div className="flex-1 bg-gray-50 overflow-y-auto p-6">
|
{
|
||||||
{selectedPage ? (
|
currentPage ? (
|
||||||
<SectionEditor
|
<CanvasRenderer
|
||||||
sections={structure?.sections || []}
|
sections={sections}
|
||||||
selectedSection={selectedSection}
|
selectedSectionId={selectedSectionId}
|
||||||
|
deviceMode={deviceMode}
|
||||||
onSelectSection={setSelectedSection}
|
onSelectSection={setSelectedSection}
|
||||||
onAddSection={handleAddSection}
|
onAddSection={addSection}
|
||||||
onDeleteSection={handleDeleteSection}
|
onDeleteSection={deleteSection}
|
||||||
onMoveSection={handleMoveSection}
|
onDuplicateSection={duplicateSection}
|
||||||
onReorderSections={handleReorderSections}
|
onMoveSection={moveSection}
|
||||||
isTemplate={selectedPage.type === 'template'}
|
onReorderSections={reorderSections}
|
||||||
cpt={selectedPage.cpt}
|
onDeviceModeChange={setDeviceMode}
|
||||||
isLoading={pageLoading}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-gray-400">
|
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Layout className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
<Layout className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||||
<p>{__('Select a page from the sidebar to start editing')}</p>
|
<p className="text-lg">{__('Select a page from the sidebar')}</p>
|
||||||
|
<p className="text-sm mt-2">{__('Edit pages and templates visually')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
|
|
||||||
{/* Right Column: Settings & Preview */}
|
{/* Right Column: Inspector Panel */}
|
||||||
<PageSettings
|
{
|
||||||
page={selectedPage}
|
currentPage && (
|
||||||
section={selectedSection}
|
<InspectorPanel
|
||||||
sections={structure?.sections || []}
|
page={currentPage}
|
||||||
onSectionUpdate={handleSectionUpdate}
|
selectedSection={selectedSection}
|
||||||
isTemplate={selectedPage?.type === 'template'}
|
isCollapsed={inspectorCollapsed}
|
||||||
availableSources={pageData?.available_sources || []}
|
isTemplate={currentPage.type === 'template'}
|
||||||
/>
|
availableSources={availableSources}
|
||||||
</div>
|
onToggleCollapse={() => setInspectorCollapsed(!inspectorCollapsed)}
|
||||||
|
onSectionPropChange={(propName, value) => {
|
||||||
|
if (selectedSectionId) {
|
||||||
|
updateSectionProp(selectedSectionId, propName, value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onLayoutChange={(layout) => {
|
||||||
|
if (selectedSectionId) {
|
||||||
|
updateSectionLayout(selectedSectionId, layout);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onColorSchemeChange={(scheme) => {
|
||||||
|
if (selectedSectionId) {
|
||||||
|
updateSectionColorScheme(selectedSectionId, scheme);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSectionStylesChange={(styles) => {
|
||||||
|
if (selectedSectionId) {
|
||||||
|
updateSectionStyles(selectedSectionId, styles);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onElementStylesChange={(fieldName, styles) => {
|
||||||
|
if (selectedSectionId) {
|
||||||
|
updateElementStyles(selectedSectionId, fieldName, styles);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDeleteSection={() => {
|
||||||
|
if (selectedSectionId) {
|
||||||
|
deleteSection(selectedSectionId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSetAsSpaLanding={() => {
|
||||||
|
if (currentPage?.id) {
|
||||||
|
setSpaLandingMutation.mutate(currentPage.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||||
|
onDeletePage={handleDeletePage}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
|
||||||
{/* Create Page Modal */}
|
{/* Create Page Modal */}
|
||||||
<CreatePageModal
|
< CreatePageModal
|
||||||
open={showCreateModal}
|
open={showCreateModal}
|
||||||
onOpenChange={setShowCreateModal}
|
onOpenChange={setShowCreateModal}
|
||||||
onCreated={(newPage) => {
|
onCreated={(newPage) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
setSelectedPage(newPage);
|
setCurrentPage(newPage);
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { CodeEditor } from '@/components/ui/code-editor';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { ArrowLeft, Eye, Edit, RotateCcw, FileText } from 'lucide-react';
|
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { markdownToHtml } from '@/lib/markdown-utils';
|
import { markdownToHtml } from '@/lib/markdown-utils';
|
||||||
@@ -38,12 +39,22 @@ export default function EditTemplate() {
|
|||||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||||
const [activeTab, setActiveTab] = useState('preview');
|
const [activeTab, setActiveTab] = useState('preview');
|
||||||
|
|
||||||
// Fetch email customization settings
|
// Send Test Email state
|
||||||
|
const [testEmailDialogOpen, setTestEmailDialogOpen] = useState(false);
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
|
||||||
|
// Fetch email customization settings (for non-color settings like logo, footer, social links)
|
||||||
const { data: emailSettings } = useQuery({
|
const { data: emailSettings } = useQuery({
|
||||||
queryKey: ['email-settings'],
|
queryKey: ['email-settings'],
|
||||||
queryFn: () => api.get('/notifications/email-settings'),
|
queryFn: () => api.get('/notifications/email-settings'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch appearance settings for unified colors
|
||||||
|
const { data: appearanceSettings } = useQuery({
|
||||||
|
queryKey: ['appearance-settings'],
|
||||||
|
queryFn: () => api.get('/appearance/settings'),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch template
|
// Fetch template
|
||||||
const { data: template, isLoading, error } = useQuery({
|
const { data: template, isLoading, error } = useQuery({
|
||||||
queryKey: ['notification-template', eventId, channelId, recipientType],
|
queryKey: ['notification-template', eventId, channelId, recipientType],
|
||||||
@@ -114,6 +125,32 @@ export default function EditTemplate() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Send test email mutation
|
||||||
|
const sendTestMutation = useMutation({
|
||||||
|
mutationFn: async (email: string) => {
|
||||||
|
return api.post(`/notifications/templates/${eventId}/${channelId}/send-test`, {
|
||||||
|
email,
|
||||||
|
recipient: recipientType,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
toast.success(data.message || __('Test email sent successfully'));
|
||||||
|
setTestEmailDialogOpen(false);
|
||||||
|
setTestEmail('');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error?.message || __('Failed to send test email'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSendTest = () => {
|
||||||
|
if (!testEmail || !testEmail.includes('@')) {
|
||||||
|
toast.error(__('Please enter a valid email address'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendTestMutation.mutate(testEmail);
|
||||||
|
};
|
||||||
|
|
||||||
// Visual mode: Update blocks → Markdown (source of truth)
|
// Visual mode: Update blocks → Markdown (source of truth)
|
||||||
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
|
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
|
||||||
setBlocks(newBlocks);
|
setBlocks(newBlocks);
|
||||||
@@ -288,14 +325,15 @@ export default function EditTemplate() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get email settings for preview
|
// Get email settings for preview - use UNIFIED appearance settings for colors
|
||||||
const settings = emailSettings || {};
|
const settings = emailSettings || {};
|
||||||
const primaryColor = settings.primary_color || '#7f54b3';
|
const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
|
||||||
const secondaryColor = settings.secondary_color || '#7f54b3';
|
const primaryColor = appearColors.primary || '#7f54b3';
|
||||||
const heroGradientStart = settings.hero_gradient_start || '#667eea';
|
const secondaryColor = appearColors.secondary || '#7f54b3';
|
||||||
const heroGradientEnd = settings.hero_gradient_end || '#764ba2';
|
const heroGradientStart = appearColors.gradientStart || '#667eea';
|
||||||
const heroTextColor = settings.hero_text_color || '#ffffff';
|
const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
|
||||||
const buttonTextColor = settings.button_text_color || '#ffffff';
|
const heroTextColor = '#ffffff'; // Always white on gradient
|
||||||
|
const buttonTextColor = '#ffffff'; // Always white on primary
|
||||||
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
|
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
|
||||||
const socialIconColor = settings.social_icon_color || 'white';
|
const socialIconColor = settings.social_icon_color || 'white';
|
||||||
const logoUrl = settings.logo_url || '';
|
const logoUrl = settings.logo_url || '';
|
||||||
@@ -307,10 +345,11 @@ export default function EditTemplate() {
|
|||||||
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
||||||
|
|
||||||
// Generate social icons HTML with PNG images
|
// Generate social icons HTML with PNG images
|
||||||
|
// Get plugin URL from config, with fallback
|
||||||
const pluginUrl =
|
const pluginUrl =
|
||||||
(window as any).woonoowData?.pluginUrl ||
|
(window as any).woonoowData?.pluginUrl ||
|
||||||
(window as any).WNW_CONFIG?.pluginUrl ||
|
(window as any).WNW_CONFIG?.pluginUrl ||
|
||||||
'';
|
'/wp-content/plugins/woonoow/';
|
||||||
const socialIconsHtml = socialLinks.length > 0 ? `
|
const socialIconsHtml = socialLinks.length > 0 ? `
|
||||||
<div style="margin-top: 16px;">
|
<div style="margin-top: 16px;">
|
||||||
${socialLinks.map((link: any) => `
|
${socialLinks.map((link: any) => `
|
||||||
@@ -414,128 +453,175 @@ export default function EditTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<>
|
||||||
title={template.event_label || __('Edit Template')}
|
<SettingsLayout
|
||||||
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
title={template.event_label || __('Edit Template')}
|
||||||
onSave={handleSave}
|
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
||||||
saveLabel={__('Save Template')}
|
onSave={handleSave}
|
||||||
isLoading={false}
|
saveLabel={__('Save Template')}
|
||||||
action={
|
isLoading={false}
|
||||||
<div className="flex items-center gap-2">
|
action={
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
// Determine if staff or customer based on event category
|
onClick={() => {
|
||||||
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
// Determine if staff or customer based on event category
|
||||||
const page = isStaffEvent ? 'staff' : 'customer';
|
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
||||||
navigate(`/settings/notifications/${page}?tab=events`);
|
const page = isStaffEvent ? 'staff' : 'customer';
|
||||||
}}
|
navigate(`/settings/notifications/${page}?tab=events`);
|
||||||
className="gap-2"
|
}}
|
||||||
title={__('Back')}
|
className="gap-2"
|
||||||
>
|
title={__('Back')}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
>
|
||||||
<span className="hidden sm:inline">{__('Back')}</span>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">{__('Back')}</span>
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={handleReset}
|
size="sm"
|
||||||
className="gap-2"
|
onClick={handleReset}
|
||||||
title={__('Reset to Default')}
|
className="gap-2"
|
||||||
>
|
title={__('Reset to Default')}
|
||||||
<RotateCcw className="h-4 w-4" />
|
>
|
||||||
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
<RotateCcw className="h-4 w-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
||||||
</div>
|
</Button>
|
||||||
}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<Card>
|
size="sm"
|
||||||
<CardContent className="pt-6 space-y-6">
|
onClick={() => setTestEmailDialogOpen(true)}
|
||||||
{/* Subject */}
|
className="gap-2"
|
||||||
<div className="space-y-2">
|
title={__('Send Test')}
|
||||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
>
|
||||||
<Input
|
<Send className="h-4 w-4" />
|
||||||
id="subject"
|
<span className="hidden sm:inline">{__('Send Test')}</span>
|
||||||
value={subject}
|
</Button>
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
placeholder={__('Enter notification subject')}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{channelId === 'email'
|
|
||||||
? __('Email subject line')
|
|
||||||
: __('Push notification title')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
{/* Body */}
|
>
|
||||||
<div className="space-y-4">
|
<Card>
|
||||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
<CardContent className="pt-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Subject */}
|
||||||
<Label>{__('Message Body')}</Label>
|
<div className="space-y-2">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||||
<TabsList className="grid grid-cols-3">
|
<Input
|
||||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
id="subject"
|
||||||
<Eye className="h-3 w-3" />
|
value={subject}
|
||||||
{__('Preview')}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
</TabsTrigger>
|
placeholder={__('Enter notification subject')}
|
||||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
/>
|
||||||
<Edit className="h-3 w-3" />
|
<p className="text-xs text-muted-foreground">
|
||||||
{__('Visual')}
|
{channelId === 'email'
|
||||||
</TabsTrigger>
|
? __('Email subject line')
|
||||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
: __('Push notification title')}
|
||||||
<FileText className="h-3 w-3" />
|
</p>
|
||||||
{__('Markdown')}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Tab */}
|
{/* Body */}
|
||||||
{activeTab === 'preview' && (
|
<div className="space-y-4">
|
||||||
<div className="border rounded-md overflow-hidden">
|
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||||
<iframe
|
<div className="flex items-center justify-between">
|
||||||
srcDoc={generatePreviewHTML()}
|
<Label>{__('Message Body')}</Label>
|
||||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||||
title={__('Email Preview')}
|
<TabsList className="grid grid-cols-3">
|
||||||
/>
|
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
{__('Preview')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
{__('Visual')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
{__('Markdown')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Visual Tab */}
|
{/* Preview Tab */}
|
||||||
{activeTab === 'visual' && (
|
{activeTab === 'preview' && (
|
||||||
<div>
|
<div className="border rounded-md overflow-hidden">
|
||||||
<EmailBuilder
|
<iframe
|
||||||
blocks={blocks}
|
srcDoc={generatePreviewHTML()}
|
||||||
onChange={handleBlocksChange}
|
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||||
variables={variableKeys}
|
title={__('Email Preview')}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
</div>
|
||||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Markdown Tab */}
|
{/* Visual Tab */}
|
||||||
{activeTab === 'markdown' && (
|
{activeTab === 'visual' && (
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<CodeEditor
|
<EmailBuilder
|
||||||
value={markdownContent}
|
blocks={blocks}
|
||||||
onChange={handleMarkdownChange}
|
onChange={handleBlocksChange}
|
||||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
variables={variableKeys}
|
||||||
supportMarkdown={true}
|
/>
|
||||||
/>
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
)}
|
||||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
|
||||||
</p>
|
{/* Markdown Tab */}
|
||||||
</div>
|
{activeTab === 'markdown' && (
|
||||||
)}
|
<div className="space-y-2">
|
||||||
|
<CodeEditor
|
||||||
|
value={markdownContent}
|
||||||
|
onChange={handleMarkdownChange}
|
||||||
|
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||||
|
supportMarkdown={true}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</SettingsLayout>
|
||||||
|
|
||||||
|
{/* Send Test Email Dialog */}
|
||||||
|
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{__('Send a test email with sample data to verify the template looks correct.')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||||
|
<Input
|
||||||
|
id="test-email"
|
||||||
|
type="email"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('The subject will be prefixed with [TEST]')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<DialogFooter>
|
||||||
</Card>
|
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
|
||||||
</SettingsLayout>
|
{__('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSendTest} disabled={sendTestMutation.isPending}>
|
||||||
|
{sendTestMutation.isPending ? __('Sending...') : __('Send Test')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,190 +219,22 @@ export default function EmailCustomization() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Brand Colors */}
|
{/* Unified Colors Notice */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={__('Brand Colors')}
|
title={__('Brand Colors')}
|
||||||
description={__('Set your primary and secondary brand colors for buttons and accents')}
|
description={__('Colors for buttons, gradients, and accents in emails')}
|
||||||
>
|
>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||||
<Label htmlFor="primary_color">{__('Primary Color')}</Label>
|
<strong>{__('Colors are now unified!')}</strong>{' '}
|
||||||
<div className="flex gap-2">
|
{__('Email colors (buttons, gradients) now use the same colors as your storefront for consistent branding.')}
|
||||||
<Input
|
</p>
|
||||||
id="primary_color"
|
<p className="text-sm text-blue-900 dark:text-blue-100 mt-2">
|
||||||
type="color"
|
{__('To change colors, go to')}{' '}
|
||||||
value={formData.primary_color}
|
<a href="#/appearance/general" className="font-medium underline hover:no-underline">
|
||||||
onChange={(e) => handleChange('primary_color', e.target.value)}
|
{__('Appearance → General → Colors')}
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
</a>
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.primary_color}
|
|
||||||
onChange={(e) => handleChange('primary_color', e.target.value)}
|
|
||||||
placeholder="#7f54b3"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{__('Used for primary buttons and main accents')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="secondary_color">{__('Secondary Color')}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="secondary_color"
|
|
||||||
type="color"
|
|
||||||
value={formData.secondary_color}
|
|
||||||
onChange={(e) => handleChange('secondary_color', e.target.value)}
|
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.secondary_color}
|
|
||||||
onChange={(e) => handleChange('secondary_color', e.target.value)}
|
|
||||||
placeholder="#7f54b3"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{__('Used for outline buttons and borders')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Hero Card Gradient */}
|
|
||||||
<SettingsCard
|
|
||||||
title={__('Hero Card Gradient')}
|
|
||||||
description={__('Customize the gradient colors for hero/success card backgrounds')}
|
|
||||||
>
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="hero_gradient_start">{__('Gradient Start')}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="hero_gradient_start"
|
|
||||||
type="color"
|
|
||||||
value={formData.hero_gradient_start}
|
|
||||||
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
|
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.hero_gradient_start}
|
|
||||||
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
|
|
||||||
placeholder="#667eea"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="hero_gradient_end">{__('Gradient End')}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="hero_gradient_end"
|
|
||||||
type="color"
|
|
||||||
value={formData.hero_gradient_end}
|
|
||||||
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
|
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.hero_gradient_end}
|
|
||||||
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
|
|
||||||
placeholder="#764ba2"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="hero_text_color">{__('Text Color')}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="hero_text_color"
|
|
||||||
type="color"
|
|
||||||
value={formData.hero_text_color}
|
|
||||||
onChange={(e) => handleChange('hero_text_color', e.target.value)}
|
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.hero_text_color}
|
|
||||||
onChange={(e) => handleChange('hero_text_color', e.target.value)}
|
|
||||||
placeholder="#ffffff"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{__('Text and heading color for hero cards (usually white)')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="mt-4 p-6 rounded-lg text-center" style={{
|
|
||||||
background: `linear-gradient(135deg, ${formData.hero_gradient_start} 0%, ${formData.hero_gradient_end} 100%)`
|
|
||||||
}}>
|
|
||||||
<h3 className="text-xl font-bold mb-2" style={{ color: formData.hero_text_color }}>{__('Preview')}</h3>
|
|
||||||
<p className="text-sm opacity-90" style={{ color: formData.hero_text_color }}>{__('This is how your hero cards will look')}</p>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Button Styling */}
|
|
||||||
<SettingsCard
|
|
||||||
title={__('Button Styling')}
|
|
||||||
description={__('Customize button text color and appearance')}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="button_text_color">{__('Button Text Color')}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="button_text_color"
|
|
||||||
type="color"
|
|
||||||
value={formData.button_text_color}
|
|
||||||
onChange={(e) => handleChange('button_text_color', e.target.value)}
|
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.button_text_color}
|
|
||||||
onChange={(e) => handleChange('button_text_color', e.target.value)}
|
|
||||||
placeholder="#ffffff"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{__('Text color for buttons (usually white for dark buttons)')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Button Preview */}
|
|
||||||
<div className="flex gap-3 flex-wrap">
|
|
||||||
<button
|
|
||||||
className="px-6 py-3 rounded-lg font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: formData.primary_color,
|
|
||||||
color: formData.button_text_color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{__('Primary Button')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="px-6 py-3 rounded-lg font-medium border-2"
|
|
||||||
style={{
|
|
||||||
borderColor: formData.secondary_color,
|
|
||||||
color: formData.secondary_color,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{__('Secondary Button')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
|||||||
@@ -22,5 +22,5 @@ module.exports = {
|
|||||||
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")]
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
|
||||||
};
|
};
|
||||||
@@ -56,22 +56,42 @@ const getAppearanceSettings = () => {
|
|||||||
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get initial route from data attribute (set by PHP based on SPA mode)
|
// Get initial route from data attribute or derive from SPA mode
|
||||||
const getInitialRoute = () => {
|
const getInitialRoute = () => {
|
||||||
const appEl = document.getElementById('woonoow-customer-app');
|
const appEl = document.getElementById('woonoow-customer-app');
|
||||||
const initialRoute = appEl?.getAttribute('data-initial-route');
|
const initialRoute = appEl?.getAttribute('data-initial-route');
|
||||||
return initialRoute || '/shop'; // Default to shop if not specified
|
if (initialRoute) return initialRoute;
|
||||||
|
|
||||||
|
// Derive from SPA mode if no explicit route
|
||||||
|
const spaMode = (window as any).woonoowCustomer?.spaMode || 'full';
|
||||||
|
if (spaMode === 'checkout_only') return '/checkout';
|
||||||
|
return '/shop'; // Default for full mode
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get front page slug from config
|
||||||
|
const getFrontPageSlug = () => {
|
||||||
|
return (window as any).woonoowCustomer?.frontPageSlug || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Router wrapper component that uses hooks requiring Router context
|
// Router wrapper component that uses hooks requiring Router context
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const initialRoute = getInitialRoute();
|
const initialRoute = getInitialRoute();
|
||||||
|
const frontPageSlug = getFrontPageSlug();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Root route redirects to initial route based on SPA mode */}
|
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
|
||||||
<Route path="/" element={<Navigate to={initialRoute} replace />} />
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
frontPageSlug ? (
|
||||||
|
<DynamicPageRenderer slug={frontPageSlug} />
|
||||||
|
) : (
|
||||||
|
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Shop Routes */}
|
{/* Shop Routes */}
|
||||||
<Route path="/shop" element={<Shop />} />
|
<Route path="/shop" element={<Shop />} />
|
||||||
@@ -128,6 +148,24 @@ function App() {
|
|||||||
const appearanceSettings = getAppearanceSettings();
|
const appearanceSettings = getAppearanceSettings();
|
||||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||||
|
|
||||||
|
// Inject gradient CSS variables
|
||||||
|
React.useEffect(() => {
|
||||||
|
// appearanceSettings is already the 'data' object from Assets.php injection
|
||||||
|
// Structure: { general: { colors: { primary, secondary, accent, text, background, gradientStart, gradientEnd } } }
|
||||||
|
const colors = appearanceSettings?.general?.colors;
|
||||||
|
if (colors) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
// Inject all color settings as CSS variables
|
||||||
|
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
|
||||||
|
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
|
||||||
|
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
|
||||||
|
if (colors.text) root.style.setProperty('--wn-text', colors.text);
|
||||||
|
if (colors.background) root.style.setProperty('--wn-background', colors.background);
|
||||||
|
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
|
||||||
|
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
|
||||||
|
}
|
||||||
|
}, [appearanceSettings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
155
customer-spa/src/components/SharedContentLayout.tsx
Normal file
155
customer-spa/src/components/SharedContentLayout.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
|
||||||
|
interface SharedContentProps {
|
||||||
|
// Content
|
||||||
|
title?: string;
|
||||||
|
text?: string; // HTML content
|
||||||
|
|
||||||
|
// Image
|
||||||
|
image?: string;
|
||||||
|
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
containerWidth?: 'full' | 'contained';
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
className?: string;
|
||||||
|
titleStyle?: React.CSSProperties;
|
||||||
|
titleClassName?: string;
|
||||||
|
textStyle?: React.CSSProperties;
|
||||||
|
textClassName?: string;
|
||||||
|
headingStyle?: React.CSSProperties; // For prose headings override
|
||||||
|
imageStyle?: React.CSSProperties;
|
||||||
|
|
||||||
|
// Pro Features (for future)
|
||||||
|
buttons?: Array<{ text: string, url: string }>;
|
||||||
|
buttonStyle?: { classNames?: string; style?: React.CSSProperties };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
image,
|
||||||
|
imagePosition = 'left',
|
||||||
|
containerWidth = 'contained',
|
||||||
|
className,
|
||||||
|
titleStyle,
|
||||||
|
titleClassName,
|
||||||
|
textStyle,
|
||||||
|
textClassName,
|
||||||
|
headingStyle,
|
||||||
|
buttons,
|
||||||
|
|
||||||
|
imageStyle,
|
||||||
|
buttonStyle
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const hasImage = !!image;
|
||||||
|
const isImageLeft = imagePosition === 'left';
|
||||||
|
const isImageRight = imagePosition === 'right';
|
||||||
|
const isImageTop = imagePosition === 'top';
|
||||||
|
const isImageBottom = imagePosition === 'bottom';
|
||||||
|
|
||||||
|
// Wrapper classes
|
||||||
|
const containerClasses = cn(
|
||||||
|
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||||
|
containerWidth === 'contained' ? 'max-w-7xl' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridClasses = cn(
|
||||||
|
'mx-auto',
|
||||||
|
hasImage && (isImageLeft || isImageRight) ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' : 'max-w-4xl'
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||||
|
|
||||||
|
const proseStyle = {
|
||||||
|
...textStyle,
|
||||||
|
'--tw-prose-headings': headingStyle?.color,
|
||||||
|
'--tw-prose-body': textStyle?.color,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
<div className={gridClasses}>
|
||||||
|
{/* Image Side */}
|
||||||
|
{hasImage && (
|
||||||
|
<div className={cn(
|
||||||
|
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||||
|
imageWrapperOrder,
|
||||||
|
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
||||||
|
)} style={imageStyle}>
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={title || 'Section Image'}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Side */}
|
||||||
|
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||||
|
{title && (
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
"tracking-tight text-current mb-6",
|
||||||
|
!titleClassName && "text-3xl font-bold sm:text-4xl",
|
||||||
|
titleClassName
|
||||||
|
)}
|
||||||
|
style={titleStyle}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{text && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'prose prose-lg max-w-none',
|
||||||
|
'prose-h1:text-3xl prose-h1:font-bold prose-h1:mt-4 prose-h1:mb-2',
|
||||||
|
'prose-h2:text-2xl prose-h2:font-bold prose-h2:mt-3 prose-h2:mb-2',
|
||||||
|
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||||
|
'prose-p:text-[var(--tw-prose-body)]',
|
||||||
|
'text-[var(--tw-prose-body)]',
|
||||||
|
className,
|
||||||
|
textClassName
|
||||||
|
)}
|
||||||
|
style={proseStyle}
|
||||||
|
dangerouslySetInnerHTML={{ __html: text }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
{buttons && buttons.length > 0 && (
|
||||||
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
|
{buttons.map((btn, idx) => (
|
||||||
|
btn.text && btn.url && (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={btn.url}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||||
|
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||||
|
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||||
|
buttonStyle?.classNames
|
||||||
|
)}
|
||||||
|
style={buttonStyle?.style}
|
||||||
|
>
|
||||||
|
{btn.text}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -68,7 +68,7 @@ export function SearchableSelect({
|
|||||||
type="button"
|
type="button"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center justify-between border !rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:border-gray-400",
|
"w-full flex items-center justify-between border rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||||
disabled && "opacity-50 cursor-not-allowed",
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ interface AppearanceSettings {
|
|||||||
thankyou: any;
|
thankyou: any;
|
||||||
account: any;
|
account: any;
|
||||||
};
|
};
|
||||||
|
menus: {
|
||||||
|
primary: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'page' | 'custom';
|
||||||
|
value: string;
|
||||||
|
target: '_self' | '_blank';
|
||||||
|
}>;
|
||||||
|
mobile: Array<any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppearanceSettings() {
|
export function useAppearanceSettings() {
|
||||||
@@ -293,4 +303,15 @@ export function useFooterSettings() {
|
|||||||
},
|
},
|
||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export function useMenuSettings() {
|
||||||
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: data?.menus?.primary ?? [],
|
||||||
|
mobile: data?.menus?.mobile ?? [],
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react';
|
import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react';
|
||||||
import { useLayout } from '../contexts/ThemeContext';
|
import { useLayout } from '../contexts/ThemeContext';
|
||||||
import { useCartStore } from '../lib/cart/store';
|
import { useCartStore } from '../lib/cart/store';
|
||||||
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
|
import { useHeaderSettings, useFooterSettings, useMenuSettings } from '../hooks/useAppearanceSettings';
|
||||||
import { SearchModal } from '../components/SearchModal';
|
import { SearchModal } from '../components/SearchModal';
|
||||||
import { NewsletterForm } from '../components/NewsletterForm';
|
import { NewsletterForm } from '../components/NewsletterForm';
|
||||||
import { LayoutWrapper } from './LayoutWrapper';
|
import { LayoutWrapper } from './LayoutWrapper';
|
||||||
@@ -51,6 +51,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
const { isEnabled } = useModules();
|
const { isEnabled } = useModules();
|
||||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
const footerSettings = useFooterSettings();
|
const footerSettings = useFooterSettings();
|
||||||
|
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
||||||
<Link to="/shop" className="flex items-center gap-3 group">
|
<Link to="/" className="flex items-center gap-3 group">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
@@ -103,9 +104,24 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
{primaryMenu.length > 0 ? (
|
||||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
primaryMenu.map(item => (
|
||||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
item.type === 'page' ? (
|
||||||
|
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
|
||||||
|
) : (
|
||||||
|
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
|
||||||
|
)
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(window as any).woonoowCustomer?.frontPageSlug && (
|
||||||
|
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
|
||||||
|
)}
|
||||||
|
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||||
|
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||||
|
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -177,9 +193,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="md:hidden border-t py-4">
|
<div className="md:hidden border-t py-4">
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<nav className="flex flex-col space-y-2 mb-4">
|
<nav className="flex flex-col space-y-2 mb-4">
|
||||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||||
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
|
item.type === 'page' ? (
|
||||||
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
|
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
|
||||||
|
) : (
|
||||||
|
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
|
||||||
|
)
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -198,9 +218,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<nav className="flex flex-col p-4">
|
<nav className="flex flex-col p-4">
|
||||||
<Link to="/shop" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Shop</Link>
|
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||||
<a href="/about" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">About</a>
|
item.type === 'page' ? (
|
||||||
<a href="/contact" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Contact</a>
|
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</Link>
|
||||||
|
) : (
|
||||||
|
<a key={item.id} href={item.value} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</a>
|
||||||
|
)
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -367,6 +391,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
const headerSettings = useHeaderSettings();
|
const headerSettings = useHeaderSettings();
|
||||||
const { isEnabled } = useModules();
|
const { isEnabled } = useModules();
|
||||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
|
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
@@ -381,7 +406,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className={`flex flex-col items-center ${paddingClass}`}>
|
<div className={`flex flex-col items-center ${paddingClass}`}>
|
||||||
{/* Logo - Centered */}
|
{/* Logo - Centered */}
|
||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<Link to="/shop" className="mb-4">
|
<Link to="/" className="mb-4">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
@@ -404,9 +429,24 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
<nav className="hidden md:flex items-center space-x-8">
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<>
|
<>
|
||||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
{primaryMenu.length > 0 ? (
|
||||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
primaryMenu.map(item => (
|
||||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
item.type === 'page' ? (
|
||||||
|
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
|
||||||
|
) : (
|
||||||
|
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
|
||||||
|
)
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(window as any).woonoowCustomer?.frontPageSlug && (
|
||||||
|
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
|
||||||
|
)}
|
||||||
|
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||||
|
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||||
|
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
@@ -456,9 +496,13 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="md:hidden border-t py-4">
|
<div className="md:hidden border-t py-4">
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<nav className="flex flex-col space-y-2 mb-4">
|
<nav className="flex flex-col space-y-2 mb-4">
|
||||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||||
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
|
item.type === 'page' ? (
|
||||||
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
|
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
|
||||||
|
) : (
|
||||||
|
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
|
||||||
|
)
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -503,6 +547,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
const headerSettings = useHeaderSettings();
|
const headerSettings = useHeaderSettings();
|
||||||
const { isEnabled } = useModules();
|
const { isEnabled } = useModules();
|
||||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
|
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
@@ -520,7 +565,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
|
|
||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Link to="/shop">
|
<Link to="/">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
@@ -543,7 +588,24 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
{(headerSettings.elements.navigation || hasActions) && (
|
{(headerSettings.elements.navigation || hasActions) && (
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
<>
|
||||||
|
{primaryMenu.length > 0 ? (
|
||||||
|
primaryMenu.map(item => (
|
||||||
|
item.type === 'page' ? (
|
||||||
|
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
|
||||||
|
) : (
|
||||||
|
<a key={item.id} href={item.value} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
|
||||||
|
)
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(window as any).woonoowCustomer?.frontPageSlug && (
|
||||||
|
<Link to="/" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
|
||||||
|
)}
|
||||||
|
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
@@ -591,7 +653,13 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="md:hidden border-t py-4">
|
<div className="md:hidden border-t py-4">
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<nav className="flex flex-col space-y-2 mb-4">
|
<nav className="flex flex-col space-y-2 mb-4">
|
||||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||||
|
item.type === 'page' ? (
|
||||||
|
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
|
||||||
|
) : (
|
||||||
|
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
|
||||||
|
)
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -656,7 +724,7 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className={`flex items-center justify-center ${heightClass}`}>
|
<div className={`flex items-center justify-center ${heightClass}`}>
|
||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<Link to="/shop">
|
<Link to="/">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
|
|||||||
@@ -19,11 +19,29 @@ interface SectionProp {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SectionStyles {
|
||||||
|
backgroundColor?: string;
|
||||||
|
backgroundImage?: string;
|
||||||
|
backgroundOverlay?: number;
|
||||||
|
paddingTop?: string;
|
||||||
|
paddingBottom?: string;
|
||||||
|
contentWidth?: 'full' | 'contained';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementStyle {
|
||||||
|
color?: string;
|
||||||
|
fontSize?: string;
|
||||||
|
fontWeight?: string;
|
||||||
|
textAlign?: 'left' | 'center' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
interface Section {
|
interface Section {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
layoutVariant?: string;
|
layoutVariant?: string;
|
||||||
colorScheme?: string;
|
colorScheme?: string;
|
||||||
|
styles?: SectionStyles;
|
||||||
|
elementStyles?: Record<string, ElementStyle>;
|
||||||
props: Record<string, SectionProp | any>;
|
props: Record<string, SectionProp | any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,32 +82,64 @@ const SECTION_COMPONENTS: Record<string, React.ComponentType<any>> = {
|
|||||||
'contact_form': ContactFormSection,
|
'contact_form': ContactFormSection,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten section props by extracting .value from {type, value} objects
|
||||||
|
* This transforms { title: { type: 'static', value: 'Hello' } }
|
||||||
|
* into { title: 'Hello' }
|
||||||
|
*/
|
||||||
|
function flattenSectionProps(props: Record<string, any>): Record<string, any> {
|
||||||
|
const flattened: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
|
||||||
|
// This is a {type, value} prop structure
|
||||||
|
flattened[key] = value.value;
|
||||||
|
} else if (value && typeof value === 'object' && 'type' in value && 'source' in value) {
|
||||||
|
// This is a dynamic prop - use source as placeholder for now
|
||||||
|
flattened[key] = `[${value.source}]`;
|
||||||
|
} else {
|
||||||
|
// Regular value, pass through
|
||||||
|
flattened[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DynamicPageRenderer
|
* DynamicPageRenderer
|
||||||
* Renders structural pages and CPT template content
|
* Renders structural pages and CPT template content
|
||||||
*/
|
*/
|
||||||
export function DynamicPageRenderer() {
|
interface DynamicPageRendererProps {
|
||||||
const { pathBase, slug } = useParams<{ pathBase?: string; slug?: string }>();
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps) {
|
||||||
|
const { pathBase, slug: paramSlug } = useParams<{ pathBase?: string; slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
|
||||||
|
// Use prop slug if provided, otherwise use param slug
|
||||||
|
const effectiveSlug = propSlug || paramSlug;
|
||||||
|
|
||||||
// Determine if this is a page or CPT content
|
// Determine if this is a page or CPT content
|
||||||
const isStructuralPage = !pathBase;
|
// If propSlug is provided, it's treated as a structural page (pathBase is undefined)
|
||||||
|
const isStructuralPage = !pathBase || !!propSlug;
|
||||||
const contentType = pathBase === 'blog' ? 'post' : pathBase;
|
const contentType = pathBase === 'blog' ? 'post' : pathBase;
|
||||||
const contentSlug = slug || '';
|
const contentSlug = effectiveSlug || '';
|
||||||
|
|
||||||
// Fetch page/content data
|
// Fetch page/content data
|
||||||
const { data: pageData, isLoading, error } = useQuery<PageData>({
|
const { data: pageData, isLoading, error } = useQuery<PageData>({
|
||||||
queryKey: ['dynamic-page', pathBase, slug],
|
queryKey: ['dynamic-page', pathBase, effectiveSlug],
|
||||||
queryFn: async (): Promise<PageData> => {
|
queryFn: async (): Promise<PageData> => {
|
||||||
if (isStructuralPage) {
|
if (isStructuralPage) {
|
||||||
// Fetch structural page
|
// Fetch structural page - api.get returns JSON directly
|
||||||
const response = await api.get(`/pages/${slug}`);
|
const response = await api.get<PageData>(`/pages/${contentSlug}`);
|
||||||
return response.data;
|
return response;
|
||||||
} else {
|
} else {
|
||||||
// Fetch CPT content with template
|
// Fetch CPT content with template
|
||||||
const response = await api.get(`/content/${contentType}/${contentSlug}`);
|
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
|
||||||
return response.data;
|
return response;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -172,13 +222,42 @@ export function DynamicPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionComponent
|
<div
|
||||||
key={section.id}
|
key={section.id}
|
||||||
id={section.id}
|
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`}
|
||||||
layout={section.layoutVariant || 'default'}
|
style={{
|
||||||
colorScheme={section.colorScheme || 'default'}
|
backgroundColor: section.styles?.backgroundColor,
|
||||||
{...section.props}
|
paddingTop: section.styles?.paddingTop,
|
||||||
/>
|
paddingBottom: section.styles?.paddingBottom,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background Image & Overlay */}
|
||||||
|
{section.styles?.backgroundImage && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-0 bg-black"
|
||||||
|
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Wrapper */}
|
||||||
|
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}>
|
||||||
|
<SectionComponent
|
||||||
|
id={section.id}
|
||||||
|
section={section} // Pass full section object for components that need raw data
|
||||||
|
layout={section.layoutVariant || 'default'}
|
||||||
|
colorScheme={section.colorScheme || 'default'}
|
||||||
|
styles={section.styles}
|
||||||
|
elementStyles={section.elementStyles}
|
||||||
|
{...flattenSectionProps(section.props || {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface CTABannerSectionProps {
|
|||||||
text?: string;
|
text?: string;
|
||||||
button_text?: string;
|
button_text?: string;
|
||||||
button_url?: string;
|
button_url?: string;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CTABannerSection({
|
export function CTABannerSection({
|
||||||
@@ -18,7 +19,36 @@ export function CTABannerSection({
|
|||||||
text,
|
text,
|
||||||
button_text,
|
button_text,
|
||||||
button_url,
|
button_url,
|
||||||
}: CTABannerSectionProps) {
|
elementStyles,
|
||||||
|
styles,
|
||||||
|
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
|
||||||
|
|
||||||
|
// Helper to get text styles (including font family)
|
||||||
|
const getTextStyles = (elementName: string) => {
|
||||||
|
const styles = elementStyles?.[elementName] || {};
|
||||||
|
return {
|
||||||
|
classNames: cn(
|
||||||
|
styles.fontSize,
|
||||||
|
styles.fontWeight,
|
||||||
|
{
|
||||||
|
'font-sans': styles.fontFamily === 'secondary',
|
||||||
|
'font-serif': styles.fontFamily === 'primary',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style: {
|
||||||
|
color: styles.color,
|
||||||
|
textAlign: styles.textAlign,
|
||||||
|
backgroundColor: styles.backgroundColor,
|
||||||
|
borderColor: styles.borderColor,
|
||||||
|
borderWidth: styles.borderWidth,
|
||||||
|
borderRadius: styles.borderRadius,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle = getTextStyles('title');
|
||||||
|
const textStyle = getTextStyles('text');
|
||||||
|
const btnStyle = getTextStyles('button_text');
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
@@ -26,7 +56,7 @@ export function CTABannerSection({
|
|||||||
'wn-section wn-cta-banner',
|
'wn-section wn-cta-banner',
|
||||||
`wn-cta-banner--${layout}`,
|
`wn-cta-banner--${layout}`,
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`,
|
||||||
'py-16 md:py-20',
|
'py-12 md:py-20',
|
||||||
{
|
{
|
||||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
|
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
|
||||||
@@ -35,21 +65,36 @@ export function CTABannerSection({
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4 text-center">
|
<div className={cn(
|
||||||
|
"mx-auto px-4 text-center",
|
||||||
|
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||||
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<h2 className="wn-cta-banner__title text-3xl md:text-4xl font-bold mb-4">
|
<h2
|
||||||
|
className={cn(
|
||||||
|
"wn-cta__title mb-6",
|
||||||
|
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||||
|
!elementStyles?.title?.fontWeight && "font-bold",
|
||||||
|
titleStyle.classNames
|
||||||
|
)}
|
||||||
|
style={titleStyle.style}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{text && (
|
{text && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
'wn-cta-banner__text text-lg md:text-xl mb-8 max-w-2xl mx-auto',
|
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
|
||||||
|
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
|
||||||
{
|
{
|
||||||
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
|
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||||
'text-gray-600': colorScheme === 'muted',
|
'text-gray-600': colorScheme === 'muted',
|
||||||
}
|
},
|
||||||
)}>
|
textStyle.classNames
|
||||||
|
)}
|
||||||
|
style={textStyle.style}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -58,12 +103,18 @@ export function CTABannerSection({
|
|||||||
<a
|
<a
|
||||||
href={button_url}
|
href={button_url}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:transform hover:scale-105',
|
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
|
||||||
{
|
!btnStyle.style?.backgroundColor && {
|
||||||
'bg-white text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
|
'bg-white': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||||
'bg-primary text-white': colorScheme === 'muted',
|
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||||
}
|
},
|
||||||
|
!btnStyle.style?.color && {
|
||||||
|
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||||
|
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||||
|
},
|
||||||
|
btnStyle.classNames
|
||||||
)}
|
)}
|
||||||
|
style={btnStyle.style}
|
||||||
>
|
>
|
||||||
{button_text}
|
{button_text}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface ContactFormSectionProps {
|
|||||||
webhook_url?: string;
|
webhook_url?: string;
|
||||||
redirect_url?: string;
|
redirect_url?: string;
|
||||||
fields?: string[];
|
fields?: string[];
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactFormSection({
|
export function ContactFormSection({
|
||||||
@@ -19,8 +20,37 @@ export function ContactFormSection({
|
|||||||
webhook_url,
|
webhook_url,
|
||||||
redirect_url,
|
redirect_url,
|
||||||
fields = ['name', 'email', 'message'],
|
fields = ['name', 'email', 'message'],
|
||||||
}: ContactFormSectionProps) {
|
elementStyles,
|
||||||
|
styles,
|
||||||
|
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
|
||||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Helper to get text styles (including font family)
|
||||||
|
const getTextStyles = (elementName: string) => {
|
||||||
|
const styles = elementStyles?.[elementName] || {};
|
||||||
|
return {
|
||||||
|
classNames: cn(
|
||||||
|
styles.fontSize,
|
||||||
|
styles.fontWeight,
|
||||||
|
{
|
||||||
|
'font-sans': styles.fontFamily === 'secondary',
|
||||||
|
'font-serif': styles.fontFamily === 'primary',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style: {
|
||||||
|
color: styles.color,
|
||||||
|
textAlign: styles.textAlign,
|
||||||
|
backgroundColor: styles.backgroundColor,
|
||||||
|
borderColor: styles.borderColor,
|
||||||
|
borderWidth: styles.borderWidth,
|
||||||
|
borderRadius: styles.borderRadius,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle = getTextStyles('title');
|
||||||
|
const buttonStyle = getTextStyles('button');
|
||||||
|
const fieldsStyle = getTextStyles('fields');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -65,14 +95,18 @@ export function ContactFormSection({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'wn-section wn-contact-form',
|
'wn-section wn-contact-form',
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`,
|
||||||
'py-16 md:py-20',
|
`wn-scheme--${colorScheme}`,
|
||||||
|
'py-12 md:py-20',
|
||||||
{
|
{
|
||||||
'bg-white': colorScheme === 'default',
|
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||||
'bg-muted': colorScheme === 'muted',
|
'bg-muted': colorScheme === 'muted',
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4">
|
<div className={cn(
|
||||||
|
"mx-auto px-4",
|
||||||
|
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||||
|
)}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'max-w-xl mx-auto',
|
'max-w-xl mx-auto',
|
||||||
{
|
{
|
||||||
@@ -80,7 +114,14 @@ export function ContactFormSection({
|
|||||||
}
|
}
|
||||||
)}>
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<h2 className="wn-contact-form__title text-3xl font-bold text-center mb-8">
|
<h2 className={cn(
|
||||||
|
"wn-contact__title text-center mb-12",
|
||||||
|
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||||
|
!elementStyles?.title?.fontWeight && "font-bold",
|
||||||
|
titleStyle.classNames
|
||||||
|
)}
|
||||||
|
style={titleStyle.style}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
@@ -101,7 +142,16 @@ export function ContactFormSection({
|
|||||||
value={formData[field] || ''}
|
value={formData[field] || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
className={cn(
|
||||||
|
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||||
|
fieldsStyle.classNames
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||||
|
color: fieldsStyle.style?.color,
|
||||||
|
borderColor: fieldsStyle.style?.borderColor,
|
||||||
|
borderRadius: fieldsStyle.style?.borderRadius,
|
||||||
|
}}
|
||||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -111,7 +161,16 @@ export function ContactFormSection({
|
|||||||
name={field}
|
name={field}
|
||||||
value={formData[field] || ''}
|
value={formData[field] || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
className={cn(
|
||||||
|
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||||
|
fieldsStyle.classNames
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||||
|
color: fieldsStyle.style?.color,
|
||||||
|
borderColor: fieldsStyle.style?.borderColor,
|
||||||
|
borderRadius: fieldsStyle.style?.borderRadius,
|
||||||
|
}}
|
||||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -128,10 +187,14 @@ export function ContactFormSection({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full py-3 px-6 bg-primary text-primary-foreground rounded-lg font-semibold',
|
'w-full py-3 px-6 rounded-lg font-semibold',
|
||||||
'hover:bg-primary/90 transition-colors',
|
'hover:opacity-90 transition-colors',
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
!buttonStyle.style?.backgroundColor && 'bg-primary',
|
||||||
|
!buttonStyle.style?.color && 'text-primary-foreground',
|
||||||
|
buttonStyle.classNames
|
||||||
)}
|
)}
|
||||||
|
style={buttonStyle.style}
|
||||||
>
|
>
|
||||||
{submitting ? 'Sending...' : 'Submit'}
|
{submitting ? 'Sending...' : 'Submit'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,46 +1,259 @@
|
|||||||
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||||
|
|
||||||
interface ContentSectionProps {
|
interface ContentSectionProps {
|
||||||
id: string;
|
section: {
|
||||||
layout?: string;
|
id: string;
|
||||||
colorScheme?: string;
|
layoutVariant?: string;
|
||||||
content?: string;
|
colorScheme?: string;
|
||||||
|
props?: {
|
||||||
|
content?: { value: string };
|
||||||
|
cta_text?: { value: string };
|
||||||
|
cta_url?: { value: string };
|
||||||
|
};
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
|
styles?: Record<string, any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentSection({
|
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||||
id,
|
default: { bg: 'bg-white', text: 'text-gray-900' },
|
||||||
layout = 'default',
|
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
|
||||||
colorScheme = 'default',
|
dark: { bg: 'bg-gray-900', text: 'text-white' },
|
||||||
content,
|
blue: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||||
}: ContentSectionProps) {
|
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||||
if (!content) return null;
|
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||||
|
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||||
|
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIDTH_CLASSES: Record<string, string> = {
|
||||||
|
default: 'max-w-screen-xl mx-auto',
|
||||||
|
contained: 'max-w-screen-md mx-auto',
|
||||||
|
full: 'w-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fontSizeToCSS = (className?: string) => {
|
||||||
|
switch (className) {
|
||||||
|
case 'text-xs': return '0.75rem';
|
||||||
|
case 'text-sm': return '0.875rem';
|
||||||
|
case 'text-base': return '1rem';
|
||||||
|
case 'text-lg': return '1.125rem';
|
||||||
|
case 'text-xl': return '1.25rem';
|
||||||
|
case 'text-2xl': return '1.5rem';
|
||||||
|
case 'text-3xl': return '1.875rem';
|
||||||
|
case 'text-4xl': return '2.25rem';
|
||||||
|
case 'text-5xl': return '3rem';
|
||||||
|
case 'text-6xl': return '3.75rem';
|
||||||
|
case 'text-7xl': return '4.5rem';
|
||||||
|
case 'text-8xl': return '6rem';
|
||||||
|
case 'text-9xl': return '8rem';
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fontWeightToCSS = (className?: string) => {
|
||||||
|
switch (className) {
|
||||||
|
case 'font-thin': return '100';
|
||||||
|
case 'font-extralight': return '200';
|
||||||
|
case 'font-light': return '300';
|
||||||
|
case 'font-normal': return '400';
|
||||||
|
case 'font-medium': return '500';
|
||||||
|
case 'font-semibold': return '600';
|
||||||
|
case 'font-bold': return '700';
|
||||||
|
case 'font-extrabold': return '800';
|
||||||
|
case 'font-black': return '900';
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to generate scoped CSS for prose elements
|
||||||
|
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
|
||||||
|
const styles: string[] = [];
|
||||||
|
const scope = `#${sectionId}`; // ContentSection uses id directly on section tag
|
||||||
|
|
||||||
|
// Headings (h1-h4)
|
||||||
|
const hs = elementStyles?.heading;
|
||||||
|
if (hs) {
|
||||||
|
const headingRules = [
|
||||||
|
hs.color && `color: ${hs.color} !important;`,
|
||||||
|
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
|
||||||
|
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
|
||||||
|
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
|
||||||
|
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
|
||||||
|
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
if (headingRules) {
|
||||||
|
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body text (p, li)
|
||||||
|
const ts = elementStyles?.text;
|
||||||
|
if (ts) {
|
||||||
|
const textRules = [
|
||||||
|
ts.color && `color: ${ts.color} !important;`,
|
||||||
|
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
||||||
|
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
|
||||||
|
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
if (textRules) {
|
||||||
|
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit Spacing & List Formatting Restorations
|
||||||
|
styles.push(`
|
||||||
|
${scope} p { margin-bottom: 1em; }
|
||||||
|
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
|
||||||
|
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
|
||||||
|
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
|
||||||
|
${scope} li { margin-bottom: 0.25em; }
|
||||||
|
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Links (a:not(.button))
|
||||||
|
const ls = elementStyles?.link;
|
||||||
|
if (ls) {
|
||||||
|
const linkRules = [
|
||||||
|
ls.color && `color: ${ls.color} !important;`,
|
||||||
|
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
|
||||||
|
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
|
||||||
|
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
if (linkRules) {
|
||||||
|
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
|
||||||
|
}
|
||||||
|
if (ls.hoverColor) {
|
||||||
|
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons (a[data-button], .button)
|
||||||
|
const bs = elementStyles?.button;
|
||||||
|
if (bs) {
|
||||||
|
const btnRules = [
|
||||||
|
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
|
||||||
|
bs.color && `color: ${bs.color} !important;`,
|
||||||
|
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
|
||||||
|
bs.padding && `padding: ${bs.padding} !important;`,
|
||||||
|
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
|
||||||
|
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
|
||||||
|
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
|
||||||
|
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
const is = elementStyles?.image;
|
||||||
|
if (is) {
|
||||||
|
const imgRules = [
|
||||||
|
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
||||||
|
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
||||||
|
is.width && `width: ${is.width} !important;`,
|
||||||
|
is.height && `height: ${is.height} !important;`,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
if (imgRules) {
|
||||||
|
styles.push(`${scope} img { ${imgRules} }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentSection({ section }: ContentSectionProps) {
|
||||||
|
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||||
|
// Default to 'default' width if not specified
|
||||||
|
const layout = section.layoutVariant || 'default';
|
||||||
|
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
|
||||||
|
|
||||||
|
const heightPreset = section.styles?.heightPreset || 'default';
|
||||||
|
|
||||||
|
const heightMap: Record<string, string> = {
|
||||||
|
'default': 'py-12 md:py-20',
|
||||||
|
'small': 'py-8 md:py-12',
|
||||||
|
'medium': 'py-16 md:py-24',
|
||||||
|
'large': 'py-24 md:py-32',
|
||||||
|
'screen': 'min-h-screen py-20 flex items-center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||||
|
|
||||||
|
const content = section.props?.content?.value || '';
|
||||||
|
|
||||||
|
// Helper to get text styles
|
||||||
|
const getTextStyles = (elementName: string) => {
|
||||||
|
const styles = section.elementStyles?.[elementName] || {};
|
||||||
|
return {
|
||||||
|
classNames: cn(
|
||||||
|
styles.fontSize,
|
||||||
|
styles.fontWeight,
|
||||||
|
{
|
||||||
|
'font-sans': styles.fontFamily === 'secondary',
|
||||||
|
'font-serif': styles.fontFamily === 'primary',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style: {
|
||||||
|
color: styles.color,
|
||||||
|
textAlign: styles.textAlign,
|
||||||
|
backgroundColor: styles.backgroundColor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentStyle = getTextStyles('content');
|
||||||
|
const headingStyle = getTextStyles('heading');
|
||||||
|
const textStyle = getTextStyles('text');
|
||||||
|
const buttonStyle = getTextStyles('button');
|
||||||
|
|
||||||
|
const containerWidth = section.styles?.contentWidth || 'contained';
|
||||||
|
const cta_text = section.props?.cta_text?.value;
|
||||||
|
const cta_url = section.props?.cta_url?.value;
|
||||||
|
|
||||||
|
// Helper to get background style for dynamic schemes
|
||||||
|
const getBackgroundStyle = () => {
|
||||||
|
if (scheme.bg === 'wn-gradient-bg') {
|
||||||
|
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||||
|
}
|
||||||
|
if (scheme.bg === 'wn-primary-bg') {
|
||||||
|
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||||
|
}
|
||||||
|
if (scheme.bg === 'wn-secondary-bg') {
|
||||||
|
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<>
|
||||||
id={id}
|
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||||
className={cn(
|
<section
|
||||||
'wn-section wn-content',
|
id={section.id}
|
||||||
`wn-scheme--${colorScheme}`,
|
className={cn(
|
||||||
'py-12 md:py-16',
|
'wn-content',
|
||||||
{
|
'px-4 md:px-8',
|
||||||
'bg-white': colorScheme === 'default',
|
heightClasses,
|
||||||
'bg-muted': colorScheme === 'muted',
|
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||||
'bg-primary/5': colorScheme === 'primary',
|
scheme.text
|
||||||
}
|
)}
|
||||||
)}
|
style={getBackgroundStyle()}
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4">
|
<SharedContentLayout
|
||||||
<div
|
text={content}
|
||||||
className={cn(
|
textStyle={textStyle.style}
|
||||||
'prose prose-lg max-w-none',
|
headingStyle={headingStyle.style}
|
||||||
{
|
containerWidth={containerWidth as any}
|
||||||
'max-w-3xl mx-auto': layout === 'narrow',
|
className={contentStyle.classNames}
|
||||||
'max-w-4xl mx-auto': layout === 'medium',
|
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||||
}
|
buttonStyle={{
|
||||||
)}
|
classNames: buttonStyle.classNames,
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
style: buttonStyle.style
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
|
||||||
interface FeatureItem {
|
interface FeatureItem {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -12,6 +13,7 @@ interface FeatureGridSectionProps {
|
|||||||
colorScheme?: string;
|
colorScheme?: string;
|
||||||
heading?: string;
|
heading?: string;
|
||||||
items?: FeatureItem[];
|
items?: FeatureItem[];
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureGridSection({
|
export function FeatureGridSection({
|
||||||
@@ -20,13 +22,44 @@ export function FeatureGridSection({
|
|||||||
colorScheme = 'default',
|
colorScheme = 'default',
|
||||||
heading,
|
heading,
|
||||||
items = [],
|
items = [],
|
||||||
}: FeatureGridSectionProps) {
|
features = [],
|
||||||
|
elementStyles,
|
||||||
|
styles,
|
||||||
|
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
|
||||||
|
// Use items or features (priority to items if both exist, but usually only one comes from props)
|
||||||
|
const listItems = items.length > 0 ? items : features;
|
||||||
const gridCols = {
|
const gridCols = {
|
||||||
'grid-2': 'md:grid-cols-2',
|
'grid-2': 'md:grid-cols-2',
|
||||||
'grid-3': 'md:grid-cols-3',
|
'grid-3': 'md:grid-cols-3',
|
||||||
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
|
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||||
}[layout] || 'md:grid-cols-3';
|
}[layout] || 'md:grid-cols-3';
|
||||||
|
|
||||||
|
// Helper to get text styles (including font family)
|
||||||
|
const getTextStyles = (elementName: string) => {
|
||||||
|
const styles = elementStyles?.[elementName] || {};
|
||||||
|
return {
|
||||||
|
classNames: cn(
|
||||||
|
styles.fontSize,
|
||||||
|
styles.fontWeight,
|
||||||
|
{
|
||||||
|
'font-sans': styles.fontFamily === 'secondary',
|
||||||
|
'font-serif': styles.fontFamily === 'primary',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style: {
|
||||||
|
color: styles.color,
|
||||||
|
textAlign: styles.textAlign,
|
||||||
|
backgroundColor: styles.backgroundColor,
|
||||||
|
borderColor: styles.borderColor,
|
||||||
|
borderWidth: styles.borderWidth,
|
||||||
|
borderRadius: styles.borderRadius,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const headingStyle = getTextStyles('heading');
|
||||||
|
const featureItemStyle = getTextStyles('feature_item');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
@@ -34,42 +67,65 @@ export function FeatureGridSection({
|
|||||||
'wn-section wn-feature-grid',
|
'wn-section wn-feature-grid',
|
||||||
`wn-feature-grid--${layout}`,
|
`wn-feature-grid--${layout}`,
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`,
|
||||||
'py-16 md:py-24',
|
'py-12 md:py-24',
|
||||||
{
|
{
|
||||||
'bg-white': colorScheme === 'default',
|
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||||
'bg-muted': colorScheme === 'muted',
|
'bg-muted': colorScheme === 'muted',
|
||||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4">
|
<div className={cn(
|
||||||
|
"mx-auto px-4",
|
||||||
|
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||||
|
)}>
|
||||||
{heading && (
|
{heading && (
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12">
|
<h2
|
||||||
|
className={cn(
|
||||||
|
"wn-features__heading text-center mb-12",
|
||||||
|
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl",
|
||||||
|
!elementStyles?.heading?.fontWeight && "font-bold",
|
||||||
|
headingStyle.classNames
|
||||||
|
)}
|
||||||
|
style={headingStyle.style}
|
||||||
|
>
|
||||||
{heading}
|
{heading}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn('grid gap-8', gridCols)}>
|
<div className={cn('grid gap-8', gridCols)}>
|
||||||
{items.map((item, index) => (
|
{listItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-feature-grid__item',
|
'wn-feature-grid__item',
|
||||||
'p-6 rounded-xl',
|
'p-6 rounded-xl',
|
||||||
{
|
!featureItemStyle.style?.backgroundColor && {
|
||||||
'bg-white shadow-lg': colorScheme !== 'primary',
|
'bg-white shadow-lg': colorScheme !== 'primary',
|
||||||
'bg-white/10': colorScheme === 'primary',
|
'bg-white/10': colorScheme === 'primary',
|
||||||
}
|
},
|
||||||
|
featureItemStyle.classNames
|
||||||
)}
|
)}
|
||||||
|
style={featureItemStyle.style}
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && (() => {
|
||||||
<span className="wn-feature-grid__icon text-4xl mb-4 block">
|
const IconComponent = (LucideIcons as any)[item.icon];
|
||||||
{item.icon}
|
if (!IconComponent) return null;
|
||||||
</span>
|
return (
|
||||||
)}
|
<div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
|
||||||
|
<IconComponent className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{item.title && (
|
{item.title && (
|
||||||
<h3 className="wn-feature-grid__item-title text-xl font-semibold mb-3">
|
<h3
|
||||||
|
className={cn(
|
||||||
|
"wn-feature-grid__item-title mb-3",
|
||||||
|
!featureItemStyle.classNames && "text-xl font-semibold"
|
||||||
|
)}
|
||||||
|
style={{ color: featureItemStyle.style?.color }}
|
||||||
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
@@ -77,11 +133,13 @@ export function FeatureGridSection({
|
|||||||
{item.description && (
|
{item.description && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
'wn-feature-grid__item-desc',
|
'wn-feature-grid__item-desc',
|
||||||
{
|
!featureItemStyle.style?.color && {
|
||||||
'text-gray-600': colorScheme !== 'primary',
|
'text-gray-600': colorScheme !== 'primary',
|
||||||
'text-white/80': colorScheme === 'primary',
|
'text-white/80': colorScheme === 'primary',
|
||||||
}
|
}
|
||||||
)}>
|
)}
|
||||||
|
style={{ color: featureItemStyle.style?.color }}
|
||||||
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface HeroSectionProps {
|
|||||||
image?: string;
|
image?: string;
|
||||||
cta_text?: string;
|
cta_text?: string;
|
||||||
cta_url?: string;
|
cta_url?: string;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroSection({
|
export function HeroSection({
|
||||||
@@ -20,11 +21,72 @@ export function HeroSection({
|
|||||||
image,
|
image,
|
||||||
cta_text,
|
cta_text,
|
||||||
cta_url,
|
cta_url,
|
||||||
}: HeroSectionProps) {
|
elementStyles,
|
||||||
|
styles,
|
||||||
|
}: HeroSectionProps & { styles?: Record<string, any> }) {
|
||||||
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
||||||
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
||||||
const isCentered = layout === 'centered' || layout === 'default';
|
const isCentered = layout === 'centered' || layout === 'default';
|
||||||
|
|
||||||
|
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage;
|
||||||
|
|
||||||
|
// Helper to get text styles (including font family)
|
||||||
|
const getTextStyles = (elementName: string) => {
|
||||||
|
const styles = elementStyles?.[elementName] || {};
|
||||||
|
return {
|
||||||
|
classNames: cn(
|
||||||
|
styles.fontSize,
|
||||||
|
styles.fontWeight,
|
||||||
|
{
|
||||||
|
'font-sans': styles.fontFamily === 'secondary',
|
||||||
|
'font-serif': styles.fontFamily === 'primary',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style: {
|
||||||
|
color: styles.color,
|
||||||
|
textAlign: styles.textAlign,
|
||||||
|
backgroundColor: styles.backgroundColor,
|
||||||
|
borderColor: styles.borderColor,
|
||||||
|
borderWidth: styles.borderWidth,
|
||||||
|
borderRadius: styles.borderRadius,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle = getTextStyles('title');
|
||||||
|
const subtitleStyle = getTextStyles('subtitle');
|
||||||
|
const ctaStyle = getTextStyles('cta_text');
|
||||||
|
|
||||||
|
const imageStyle = elementStyles?.['image'] || {};
|
||||||
|
|
||||||
|
// Determine height classes
|
||||||
|
const heightPreset = styles?.heightPreset || 'default';
|
||||||
|
const heightMap: Record<string, string> = {
|
||||||
|
'default': 'py-12 md:py-24', // Original Default
|
||||||
|
'small': 'py-8 md:py-16',
|
||||||
|
'medium': 'py-16 md:py-32',
|
||||||
|
'large': 'py-24 md:py-48',
|
||||||
|
'screen': 'min-h-screen py-20 flex items-center',
|
||||||
|
};
|
||||||
|
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||||
|
|
||||||
|
// Helper to get background style for dynamic schemes
|
||||||
|
const getBackgroundStyle = () => {
|
||||||
|
if (hasCustomBackground) return undefined;
|
||||||
|
if (colorScheme === 'gradient') {
|
||||||
|
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||||
|
}
|
||||||
|
if (colorScheme === 'primary') {
|
||||||
|
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||||
|
}
|
||||||
|
if (colorScheme === 'secondary') {
|
||||||
|
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
@@ -33,16 +95,15 @@ export function HeroSection({
|
|||||||
`wn-hero--${layout}`,
|
`wn-hero--${layout}`,
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`,
|
||||||
'relative overflow-hidden',
|
'relative overflow-hidden',
|
||||||
{
|
isDynamicScheme && 'text-white',
|
||||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
colorScheme === 'muted' && !hasCustomBackground && 'bg-muted',
|
||||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
|
|
||||||
'bg-muted': colorScheme === 'muted',
|
|
||||||
'bg-gradient-to-r from-primary/10 to-secondary/10': colorScheme === 'gradient',
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
|
style={getBackgroundStyle()}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'container mx-auto px-4 py-16 md:py-24',
|
'mx-auto px-4',
|
||||||
|
heightClasses,
|
||||||
|
styles?.contentWidth === 'full' ? 'w-full' : 'container',
|
||||||
{
|
{
|
||||||
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
||||||
'text-center': isCentered,
|
'text-center': isCentered,
|
||||||
@@ -51,11 +112,24 @@ export function HeroSection({
|
|||||||
{/* Image - Left */}
|
{/* Image - Left */}
|
||||||
{image && isImageLeft && (
|
{image && isImageLeft && (
|
||||||
<div className="w-full md:w-1/2">
|
<div className="w-full md:w-1/2">
|
||||||
<img
|
<div
|
||||||
src={image}
|
className="rounded-lg shadow-xl overflow-hidden"
|
||||||
alt={title || 'Hero image'}
|
style={{
|
||||||
className="w-full h-auto rounded-lg shadow-xl"
|
backgroundColor: imageStyle.backgroundColor,
|
||||||
/>
|
width: imageStyle.width || 'auto',
|
||||||
|
maxWidth: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={title || 'Hero image'}
|
||||||
|
className="w-full h-auto block"
|
||||||
|
style={{
|
||||||
|
objectFit: imageStyle.objectFit,
|
||||||
|
height: imageStyle.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -68,21 +142,68 @@ export function HeroSection({
|
|||||||
}
|
}
|
||||||
)}>
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<h1 className="wn-hero__title text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-6">
|
<h1
|
||||||
|
className={cn(
|
||||||
|
"wn-hero__title mb-6 leading-tight",
|
||||||
|
!elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
|
||||||
|
!elementStyles?.title?.fontWeight && "font-bold",
|
||||||
|
titleStyle.classNames
|
||||||
|
)}
|
||||||
|
style={titleStyle.style}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="wn-hero__subtitle text-lg md:text-xl text-opacity-80 mb-8">
|
<p
|
||||||
|
className={cn(
|
||||||
|
"wn-hero__subtitle text-opacity-80 mb-8",
|
||||||
|
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
|
||||||
|
subtitleStyle.classNames
|
||||||
|
)}
|
||||||
|
style={subtitleStyle.style}
|
||||||
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Centered Image */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden",
|
||||||
|
imageStyle.width ? "" : "max-w-4xl"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
|
||||||
|
>
|
||||||
|
{image && isCentered && (
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={title || 'Hero image'}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-[inherit]",
|
||||||
|
!imageStyle.height && "h-auto",
|
||||||
|
!imageStyle.objectFit && "object-cover"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
objectFit: imageStyle.objectFit,
|
||||||
|
height: imageStyle.height,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{cta_text && cta_url && (
|
{cta_text && cta_url && (
|
||||||
<a
|
<a
|
||||||
href={cta_url}
|
href={cta_url}
|
||||||
className="wn-hero__cta inline-block px-8 py-3 bg-primary text-primary-foreground rounded-lg font-semibold hover:bg-primary/90 transition-colors"
|
className={cn(
|
||||||
|
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
|
||||||
|
!ctaStyle.style?.backgroundColor && "bg-primary",
|
||||||
|
!ctaStyle.style?.color && "text-primary-foreground",
|
||||||
|
ctaStyle.classNames
|
||||||
|
)}
|
||||||
|
style={ctaStyle.style}
|
||||||
>
|
>
|
||||||
{cta_text}
|
{cta_text}
|
||||||
</a>
|
</a>
|
||||||
@@ -92,22 +213,24 @@ export function HeroSection({
|
|||||||
{/* Image - Right */}
|
{/* Image - Right */}
|
||||||
{image && isImageRight && (
|
{image && isImageRight && (
|
||||||
<div className="w-full md:w-1/2">
|
<div className="w-full md:w-1/2">
|
||||||
<img
|
<div
|
||||||
src={image}
|
className="rounded-lg shadow-xl overflow-hidden"
|
||||||
alt={title || 'Hero image'}
|
style={{
|
||||||
className="w-full h-auto rounded-lg shadow-xl"
|
backgroundColor: imageStyle.backgroundColor,
|
||||||
/>
|
width: imageStyle.width || 'auto',
|
||||||
</div>
|
maxWidth: '100%'
|
||||||
)}
|
}}
|
||||||
|
>
|
||||||
{/* Centered Image */}
|
<img
|
||||||
{image && isCentered && (
|
src={image}
|
||||||
<div className="mt-12">
|
alt={title || 'Hero image'}
|
||||||
<img
|
className="w-full h-auto block"
|
||||||
src={image}
|
style={{
|
||||||
alt={title || 'Hero image'}
|
objectFit: imageStyle.objectFit,
|
||||||
className="w-full max-w-4xl mx-auto h-auto rounded-lg shadow-xl"
|
height: imageStyle.height,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||||
|
|
||||||
interface ImageTextSectionProps {
|
interface ImageTextSectionProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,6 +8,7 @@ interface ImageTextSectionProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageTextSection({
|
export function ImageTextSection({
|
||||||
@@ -16,60 +18,87 @@ export function ImageTextSection({
|
|||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
image,
|
image,
|
||||||
}: ImageTextSectionProps) {
|
cta_text,
|
||||||
|
cta_url,
|
||||||
|
elementStyles,
|
||||||
|
styles,
|
||||||
|
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
|
||||||
const isImageLeft = layout === 'image-left' || layout === 'left';
|
const isImageLeft = layout === 'image-left' || layout === 'left';
|
||||||
const isImageRight = layout === 'image-right' || layout === 'right';
|
const isImageRight = layout === 'image-right' || layout === 'right';
|
||||||
|
|
||||||
|
// Helper to get text styles (including font family)
|
||||||
|
const getTextStyles = (elementName: string) => {
|
||||||
|
const styles = elementStyles?.[elementName] || {};
|
||||||
|
return {
|
||||||
|
classNames: cn(
|
||||||
|
styles.fontSize,
|
||||||
|
styles.fontWeight,
|
||||||
|
{
|
||||||
|
'font-sans': styles.fontFamily === 'secondary',
|
||||||
|
'font-serif': styles.fontFamily === 'primary',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style: {
|
||||||
|
color: styles.color,
|
||||||
|
textAlign: styles.textAlign,
|
||||||
|
backgroundColor: styles.backgroundColor,
|
||||||
|
borderColor: styles.borderColor,
|
||||||
|
borderWidth: styles.borderWidth,
|
||||||
|
borderRadius: styles.borderRadius,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle = getTextStyles('title');
|
||||||
|
const textStyle = getTextStyles('text');
|
||||||
|
const buttonStyle = getTextStyles('button');
|
||||||
|
|
||||||
|
const imageStyle = elementStyles?.['image'] || {};
|
||||||
|
|
||||||
|
// Height preset support
|
||||||
|
const heightPreset = styles?.heightPreset || 'default';
|
||||||
|
const heightMap: Record<string, string> = {
|
||||||
|
'default': 'py-12 md:py-24',
|
||||||
|
'small': 'py-8 md:py-16',
|
||||||
|
'medium': 'py-16 md:py-32',
|
||||||
|
'large': 'py-24 md:py-48',
|
||||||
|
'screen': 'min-h-screen py-20 flex items-center',
|
||||||
|
};
|
||||||
|
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'wn-section wn-image-text',
|
'wn-section wn-image-text',
|
||||||
`wn-image-text--${layout}`,
|
|
||||||
`wn-scheme--${colorScheme}`,
|
`wn-scheme--${colorScheme}`,
|
||||||
'py-16 md:py-24',
|
heightClasses,
|
||||||
{
|
{
|
||||||
'bg-white': colorScheme === 'default',
|
|
||||||
'bg-muted': colorScheme === 'muted',
|
'bg-muted': colorScheme === 'muted',
|
||||||
'bg-primary/5': colorScheme === 'primary',
|
'bg-primary/5': colorScheme === 'primary',
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4">
|
<SharedContentLayout
|
||||||
<div className={cn(
|
title={title}
|
||||||
'flex flex-col md:flex-row items-center gap-8 md:gap-16',
|
text={text}
|
||||||
{
|
image={image}
|
||||||
'md:flex-row-reverse': isImageRight,
|
imagePosition={isImageRight ? 'right' : 'left'}
|
||||||
}
|
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
|
||||||
)}>
|
titleStyle={titleStyle.style}
|
||||||
{/* Image */}
|
titleClassName={titleStyle.classNames}
|
||||||
{image && (
|
textStyle={textStyle.style}
|
||||||
<div className="w-full md:w-1/2">
|
textClassName={textStyle.classNames}
|
||||||
<img
|
imageStyle={{
|
||||||
src={image}
|
backgroundColor: imageStyle.backgroundColor,
|
||||||
alt={title || 'Section image'}
|
objectFit: imageStyle.objectFit,
|
||||||
className="w-full h-auto rounded-xl shadow-lg"
|
}}
|
||||||
/>
|
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||||
</div>
|
buttonStyle={{
|
||||||
)}
|
classNames: buttonStyle.classNames,
|
||||||
|
style: buttonStyle.style
|
||||||
{/* Content */}
|
}}
|
||||||
<div className="w-full md:w-1/2">
|
/>
|
||||||
{title && (
|
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{text && (
|
|
||||||
<div
|
|
||||||
className="prose prose-lg text-gray-600"
|
|
||||||
dangerouslySetInnerHTML={{ __html: text }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default function Shop() {
|
|||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ class AppearanceController {
|
|||||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Save menu settings
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/appearance/menus', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'save_menus'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
// Save page-specific settings
|
// Save page-specific settings
|
||||||
register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [
|
register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
@@ -73,7 +80,11 @@ class AppearanceController {
|
|||||||
* Get all appearance settings
|
* Get all appearance settings
|
||||||
*/
|
*/
|
||||||
public static function get_settings(WP_REST_Request $request) {
|
public static function get_settings(WP_REST_Request $request) {
|
||||||
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
$stored = get_option(self::OPTION_KEY, []);
|
||||||
|
$defaults = self::get_default_settings();
|
||||||
|
|
||||||
|
// Merge stored with defaults to ensure all fields exist (recursive)
|
||||||
|
$settings = array_replace_recursive($defaults, $stored);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -85,7 +96,11 @@ class AppearanceController {
|
|||||||
* Save general settings
|
* Save general settings
|
||||||
*/
|
*/
|
||||||
public static function save_general(WP_REST_Request $request) {
|
public static function save_general(WP_REST_Request $request) {
|
||||||
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
$settings = get_option(self::OPTION_KEY, []);
|
||||||
|
$defaults = self::get_default_settings();
|
||||||
|
$settings = array_replace_recursive($defaults, $settings);
|
||||||
|
|
||||||
|
$colors = $request->get_param('colors') ?? [];
|
||||||
|
|
||||||
$general_data = [
|
$general_data = [
|
||||||
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
||||||
@@ -101,11 +116,13 @@ class AppearanceController {
|
|||||||
'scale' => floatval($request->get_param('typography')['scale'] ?? 1.0),
|
'scale' => floatval($request->get_param('typography')['scale'] ?? 1.0),
|
||||||
],
|
],
|
||||||
'colors' => [
|
'colors' => [
|
||||||
'primary' => sanitize_hex_color($request->get_param('colors')['primary'] ?? '#1a1a1a'),
|
'primary' => sanitize_hex_color($colors['primary'] ?? '#1a1a1a'),
|
||||||
'secondary' => sanitize_hex_color($request->get_param('colors')['secondary'] ?? '#6b7280'),
|
'secondary' => sanitize_hex_color($colors['secondary'] ?? '#6b7280'),
|
||||||
'accent' => sanitize_hex_color($request->get_param('colors')['accent'] ?? '#3b82f6'),
|
'accent' => sanitize_hex_color($colors['accent'] ?? '#3b82f6'),
|
||||||
'text' => sanitize_hex_color($request->get_param('colors')['text'] ?? '#111827'),
|
'text' => sanitize_hex_color($colors['text'] ?? '#111827'),
|
||||||
'background' => sanitize_hex_color($request->get_param('colors')['background'] ?? '#ffffff'),
|
'background' => sanitize_hex_color($colors['background'] ?? '#ffffff'),
|
||||||
|
'gradientStart' => sanitize_hex_color($colors['gradientStart'] ?? '#9333ea'),
|
||||||
|
'gradientEnd' => sanitize_hex_color($colors['gradientEnd'] ?? '#3b82f6'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -231,6 +248,44 @@ class AppearanceController {
|
|||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save menu settings
|
||||||
|
*/
|
||||||
|
public static function save_menus(WP_REST_Request $request) {
|
||||||
|
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||||
|
|
||||||
|
$menus = $request->get_param('menus') ?? [];
|
||||||
|
|
||||||
|
// Sanitize menus
|
||||||
|
$sanitized_menus = [
|
||||||
|
'primary' => [],
|
||||||
|
'mobile' => [], // Optional separate mobile menu
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (['primary', 'mobile'] as $location) {
|
||||||
|
if (isset($menus[$location]) && is_array($menus[$location])) {
|
||||||
|
foreach ($menus[$location] as $item) {
|
||||||
|
$sanitized_menus[$location][] = [
|
||||||
|
'id' => sanitize_text_field($item['id'] ?? uniqid()),
|
||||||
|
'label' => sanitize_text_field($item['label'] ?? ''),
|
||||||
|
'type' => sanitize_text_field($item['type'] ?? 'page'), // page, custom
|
||||||
|
'value' => sanitize_text_field($item['value'] ?? ''), // slug or url
|
||||||
|
'target' => sanitize_text_field($item['target'] ?? '_self'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings['menus'] = $sanitized_menus;
|
||||||
|
update_option(self::OPTION_KEY, $settings);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Menu settings saved successfully',
|
||||||
|
'data' => $sanitized_menus,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save page-specific settings
|
* Save page-specific settings
|
||||||
*/
|
*/
|
||||||
@@ -389,11 +444,23 @@ class AppearanceController {
|
|||||||
'sort_order' => 'ASC',
|
'sort_order' => 'ASC',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pages_list = array_map(function($page) {
|
$store_pages = [
|
||||||
|
(int) get_option('woocommerce_shop_page_id'),
|
||||||
|
(int) get_option('woocommerce_cart_page_id'),
|
||||||
|
(int) get_option('woocommerce_checkout_page_id'),
|
||||||
|
(int) get_option('woocommerce_myaccount_page_id'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$pages_list = array_map(function($page) use ($store_pages) {
|
||||||
|
$is_woonoow = !empty(get_post_meta($page->ID, '_wn_page_structure', true));
|
||||||
|
$is_store = in_array((int)$page->ID, $store_pages, true);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $page->ID,
|
'id' => $page->ID,
|
||||||
'title' => $page->post_title,
|
'title' => $page->post_title,
|
||||||
'slug' => $page->post_name,
|
'slug' => $page->post_name,
|
||||||
|
'is_woonoow_page' => $is_woonoow,
|
||||||
|
'is_store_page' => $is_store,
|
||||||
];
|
];
|
||||||
}, $pages);
|
}, $pages);
|
||||||
|
|
||||||
@@ -427,6 +494,8 @@ class AppearanceController {
|
|||||||
'accent' => '#3b82f6',
|
'accent' => '#3b82f6',
|
||||||
'text' => '#111827',
|
'text' => '#111827',
|
||||||
'background' => '#ffffff',
|
'background' => '#ffffff',
|
||||||
|
'gradientStart' => '#9333ea',
|
||||||
|
'gradientEnd' => '#3b82f6',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'header' => [
|
'header' => [
|
||||||
@@ -458,6 +527,14 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
'social_links' => [],
|
'social_links' => [],
|
||||||
],
|
],
|
||||||
|
'menus' => [
|
||||||
|
'primary' => [
|
||||||
|
['id' => 'home', 'label' => 'Home', 'type' => 'page', 'value' => '/', 'target' => '_self'],
|
||||||
|
['id' => 'shop', 'label' => 'Shop', 'type' => 'page', 'value' => 'shop', 'target' => '_self'],
|
||||||
|
],
|
||||||
|
// Fallback for mobile if empty is to use primary
|
||||||
|
'mobile' => [],
|
||||||
|
],
|
||||||
'pages' => [
|
'pages' => [
|
||||||
'shop' => [
|
'shop' => [
|
||||||
'layout' => [
|
'layout' => [
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class Menu {
|
|||||||
add_action('admin_head', [__CLASS__, 'localize_wc_menus'], 999);
|
add_action('admin_head', [__CLASS__, 'localize_wc_menus'], 999);
|
||||||
// Add link to standalone admin in admin bar
|
// Add link to standalone admin in admin bar
|
||||||
add_action('admin_bar_menu', [__CLASS__, 'add_admin_bar_link'], 100);
|
add_action('admin_bar_menu', [__CLASS__, 'add_admin_bar_link'], 100);
|
||||||
|
|
||||||
|
// Add custom state for SPA Front Page
|
||||||
|
add_filter('display_post_states', [__CLASS__, 'add_spa_page_state'], 10, 2);
|
||||||
}
|
}
|
||||||
public static function register() {
|
public static function register() {
|
||||||
add_menu_page(
|
add_menu_page(
|
||||||
@@ -133,4 +136,23 @@ class Menu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add "WooNooW SPA Page" state to the pages list
|
||||||
|
*
|
||||||
|
* @param array $states Array of post states.
|
||||||
|
* @param \WP_Post $post Current post object.
|
||||||
|
* @return array Modified post states.
|
||||||
|
*/
|
||||||
|
public static function add_spa_page_state($states, $post) {
|
||||||
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||||
|
|
||||||
|
if ((int)$post->ID === (int)$spa_frontpage_id) {
|
||||||
|
$states['spa_frontpage'] = __('WooNooW Front Page', 'woonoow');
|
||||||
|
} elseif (!empty(get_post_meta($post->ID, '_wn_page_structure', true))) {
|
||||||
|
$states['woonoow_page'] = __('WooNooW Page', 'woonoow');
|
||||||
|
}
|
||||||
|
return $states;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -226,6 +226,26 @@ class NotificationsController {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// POST /woonoow/v1/notifications/templates/:eventId/:channelId/send-test
|
||||||
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/templates/(?P<eventId>[a-zA-Z0-9_-]+)/(?P<channelId>[a-zA-Z0-9_-]+)/send-test', [
|
||||||
|
[
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [$this, 'send_test_email'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
'args' => [
|
||||||
|
'email' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'sanitize_callback' => 'sanitize_email',
|
||||||
|
],
|
||||||
|
'recipient' => [
|
||||||
|
'default' => 'customer',
|
||||||
|
'type' => 'string',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -931,4 +951,411 @@ class NotificationsController {
|
|||||||
'per_page' => $per_page,
|
'per_page' => $per_page,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email for a notification template
|
||||||
|
*
|
||||||
|
* @param WP_REST_Request $request Request object
|
||||||
|
* @return WP_REST_Response|WP_Error
|
||||||
|
*/
|
||||||
|
public function send_test_email(WP_REST_Request $request) {
|
||||||
|
$event_id = $request->get_param('eventId');
|
||||||
|
$channel_id = $request->get_param('channelId');
|
||||||
|
$recipient_type = $request->get_param('recipient') ?? 'customer';
|
||||||
|
$to_email = $request->get_param('email');
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
if (!is_email($to_email)) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'invalid_email',
|
||||||
|
__('Invalid email address', 'woonoow'),
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only support email channel for test
|
||||||
|
if ($channel_id !== 'email') {
|
||||||
|
return new \WP_Error(
|
||||||
|
'unsupported_channel',
|
||||||
|
__('Test sending is only available for email channel', 'woonoow'),
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template
|
||||||
|
$template = TemplateProvider::get_template($event_id, $channel_id, $recipient_type);
|
||||||
|
|
||||||
|
if (!$template) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'template_not_found',
|
||||||
|
__('Template not found', 'woonoow'),
|
||||||
|
['status' => 404]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sample data for variables
|
||||||
|
$sample_data = $this->get_sample_data_for_event($event_id);
|
||||||
|
|
||||||
|
// Replace variables in subject and body
|
||||||
|
$subject = '[TEST] ' . $this->replace_variables($template['subject'] ?? '', $sample_data);
|
||||||
|
$body_markdown = $this->replace_variables($template['body'] ?? '', $sample_data);
|
||||||
|
|
||||||
|
// Render email using EmailRenderer
|
||||||
|
$email_renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||||
|
|
||||||
|
// We need to manually render since we're not triggering a real event
|
||||||
|
$html = $this->render_test_email($body_markdown, $subject, $sample_data);
|
||||||
|
|
||||||
|
// Set content type to HTML
|
||||||
|
$headers = ['Content-Type: text/html; charset=UTF-8'];
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
$sent = wp_mail($to_email, $subject, $html, $headers);
|
||||||
|
|
||||||
|
if (!$sent) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'send_failed',
|
||||||
|
__('Failed to send test email. Check your mail server configuration.', 'woonoow'),
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $to_email),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sample data for an event type
|
||||||
|
*
|
||||||
|
* @param string $event_id
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private function get_sample_data_for_event($event_id) {
|
||||||
|
$base_data = [
|
||||||
|
'site_name' => get_bloginfo('name'),
|
||||||
|
'store_name' => get_bloginfo('name'),
|
||||||
|
'store_url' => home_url(),
|
||||||
|
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||||
|
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||||
|
'support_email' => get_option('admin_email'),
|
||||||
|
'current_year' => date('Y'),
|
||||||
|
'customer_name' => 'John Doe',
|
||||||
|
'customer_first_name' => 'John',
|
||||||
|
'customer_last_name' => 'Doe',
|
||||||
|
'customer_email' => 'john@example.com',
|
||||||
|
'customer_phone' => '+1 234 567 8900',
|
||||||
|
'login_url' => wp_login_url(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Order-related events
|
||||||
|
if (strpos($event_id, 'order') !== false) {
|
||||||
|
$base_data = array_merge($base_data, [
|
||||||
|
'order_number' => '12345',
|
||||||
|
'order_id' => '12345',
|
||||||
|
'order_date' => date('F j, Y'),
|
||||||
|
'order_total' => wc_price(129.99),
|
||||||
|
'order_subtotal' => wc_price(109.99),
|
||||||
|
'order_tax' => wc_price(10.00),
|
||||||
|
'order_shipping' => wc_price(10.00),
|
||||||
|
'order_discount' => wc_price(0),
|
||||||
|
'order_status' => 'Processing',
|
||||||
|
'order_url' => '#',
|
||||||
|
'payment_method' => 'Credit Card',
|
||||||
|
'payment_status' => 'Paid',
|
||||||
|
'payment_date' => date('F j, Y'),
|
||||||
|
'transaction_id' => 'TXN123456789',
|
||||||
|
'shipping_method' => 'Standard Shipping',
|
||||||
|
'estimated_delivery' => date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days')),
|
||||||
|
'completion_date' => date('F j, Y'),
|
||||||
|
'billing_address' => '123 Main St, City, State 12345, Country',
|
||||||
|
'shipping_address' => '123 Main St, City, State 12345, Country',
|
||||||
|
'tracking_number' => 'TRACK123456',
|
||||||
|
'tracking_url' => '#',
|
||||||
|
'shipping_carrier' => 'Standard Carrier',
|
||||||
|
'payment_retry_url' => '#',
|
||||||
|
'review_url' => '#',
|
||||||
|
'order_items' => $this->get_sample_order_items_html(),
|
||||||
|
'order_items_table' => $this->get_sample_order_items_html(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer account events
|
||||||
|
if (strpos($event_id, 'customer') !== false || strpos($event_id, 'account') !== false) {
|
||||||
|
$base_data = array_merge($base_data, [
|
||||||
|
'customer_username' => 'johndoe',
|
||||||
|
'user_temp_password' => 'SamplePass123',
|
||||||
|
'reset_link' => '#',
|
||||||
|
'reset_key' => 'abc123xyz',
|
||||||
|
'user_login' => 'johndoe',
|
||||||
|
'user_email' => 'john@example.com',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sample order items HTML
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function get_sample_order_items_html() {
|
||||||
|
return '<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f5f5f5;">
|
||||||
|
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #ddd;">Product</th>
|
||||||
|
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #ddd;">Qty</th>
|
||||||
|
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #ddd;">Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||||
|
<strong>Sample Product</strong><br>
|
||||||
|
<span style="color: #666; font-size: 13px;">Size: M, Color: Blue</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">2</td>
|
||||||
|
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">' . wc_price(59.98) . '</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||||
|
<strong>Another Product</strong><br>
|
||||||
|
<span style="color: #666; font-size: 13px;">Option: Standard</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">1</td>
|
||||||
|
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">' . wc_price(50.01) . '</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace variables in text
|
||||||
|
*
|
||||||
|
* @param string $text
|
||||||
|
* @param array $variables
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function replace_variables($text, $variables) {
|
||||||
|
foreach ($variables as $key => $value) {
|
||||||
|
$text = str_replace('{' . $key . '}', $value, $text);
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render test email HTML
|
||||||
|
*
|
||||||
|
* @param string $body_markdown
|
||||||
|
* @param string $subject
|
||||||
|
* @param array $variables
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function render_test_email($body_markdown, $subject, $variables) {
|
||||||
|
// Parse cards
|
||||||
|
$content = $this->parse_cards_for_test($body_markdown);
|
||||||
|
|
||||||
|
// Get appearance settings for colors
|
||||||
|
$appearance = get_option('woonoow_appearance_settings', []);
|
||||||
|
$colors = $appearance['general']['colors'] ?? [];
|
||||||
|
$primary_color = $colors['primary'] ?? '#7f54b3';
|
||||||
|
$secondary_color = $colors['secondary'] ?? '#7f54b3';
|
||||||
|
$hero_gradient_start = $colors['gradientStart'] ?? '#667eea';
|
||||||
|
$hero_gradient_end = $colors['gradientEnd'] ?? '#764ba2';
|
||||||
|
|
||||||
|
// Get email settings for branding
|
||||||
|
$email_settings = get_option('woonoow_email_settings', []);
|
||||||
|
$logo_url = $email_settings['logo_url'] ?? '';
|
||||||
|
$header_text = $email_settings['header_text'] ?? $variables['store_name'];
|
||||||
|
$footer_text = $email_settings['footer_text'] ?? sprintf('© %s %s. All rights reserved.', date('Y'), $variables['store_name']);
|
||||||
|
$footer_text = str_replace('{current_year}', date('Y'), $footer_text);
|
||||||
|
|
||||||
|
// Build header
|
||||||
|
if (!empty($logo_url)) {
|
||||||
|
$header = sprintf(
|
||||||
|
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
|
||||||
|
esc_url($variables['store_url']),
|
||||||
|
esc_url($logo_url),
|
||||||
|
esc_attr($variables['store_name'])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$header = sprintf(
|
||||||
|
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
|
||||||
|
esc_url($variables['store_url']),
|
||||||
|
esc_html($header_text)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full HTML
|
||||||
|
$html = '<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>' . esc_html($subject) . '</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: "Inter", Arial, sans-serif; background: #f8f8f8; margin: 0; padding: 20px; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; }
|
||||||
|
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
||||||
|
.card-gutter { padding: 0 16px; }
|
||||||
|
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; width: 100%; box-sizing: border-box; }
|
||||||
|
.card-hero { background: linear-gradient(135deg, ' . esc_attr($hero_gradient_start) . ' 0%, ' . esc_attr($hero_gradient_end) . ' 100%); color: #ffffff; }
|
||||||
|
.card-hero * { color: #ffffff !important; }
|
||||||
|
.card-success { background-color: #f0fdf4; }
|
||||||
|
.card-info { background-color: #f0f7ff; }
|
||||||
|
.card-warning { background-color: #fff8e1; }
|
||||||
|
.card-basic { background: none; padding: 0; }
|
||||||
|
h1, h2, h3 { margin-top: 0; color: #333; }
|
||||||
|
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
|
||||||
|
.button { display: inline-block; background: ' . esc_attr($primary_color) . '; color: #ffffff !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||||
|
.button-outline { display: inline-block; background: transparent; color: ' . esc_attr($secondary_color) . ' !important; padding: 12px 26px; border: 2px solid ' . esc_attr($secondary_color) . '; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||||
|
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">' . $header . '</div>
|
||||||
|
<div class="card-gutter">' . $content . '</div>
|
||||||
|
<div class="footer"><p>' . nl2br(esc_html($footer_text)) . '</p></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse cards for test email
|
||||||
|
*
|
||||||
|
* @param string $content
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function parse_cards_for_test($content) {
|
||||||
|
// Parse [card:type] syntax
|
||||||
|
$content = preg_replace_callback(
|
||||||
|
'/\[card:(\w+)\](.*?)\[\/card\]/s',
|
||||||
|
function($matches) {
|
||||||
|
$type = $matches[1];
|
||||||
|
$card_content = $this->markdown_to_html($matches[2]);
|
||||||
|
return '<div class="card card-' . esc_attr($type) . '">' . $card_content . '</div>';
|
||||||
|
},
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse [card type="..."] syntax
|
||||||
|
$content = preg_replace_callback(
|
||||||
|
'/\[card([^\]]*)\](.*?)\[\/card\]/s',
|
||||||
|
function($matches) {
|
||||||
|
$attrs = $matches[1];
|
||||||
|
$card_content = $this->markdown_to_html($matches[2]);
|
||||||
|
$type = 'default';
|
||||||
|
if (preg_match('/type=["\']([^"\']+)["\']/', $attrs, $type_match)) {
|
||||||
|
$type = $type_match[1];
|
||||||
|
}
|
||||||
|
return '<div class="card card-' . esc_attr($type) . '">' . $card_content . '</div>';
|
||||||
|
},
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse buttons - new [button:style](url)Text[/button] syntax
|
||||||
|
$content = preg_replace_callback(
|
||||||
|
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||||
|
function($matches) {
|
||||||
|
$style = $matches[1];
|
||||||
|
$url = $matches[2];
|
||||||
|
$text = trim($matches[3]);
|
||||||
|
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||||
|
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
|
||||||
|
},
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse buttons - old [button url="..." style="..."]Text[/button] syntax
|
||||||
|
$content = preg_replace_callback(
|
||||||
|
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](\w+)["\'])?\]([^\[]+)\[\/button\]/',
|
||||||
|
function($matches) {
|
||||||
|
$url = $matches[1];
|
||||||
|
$style = $matches[2] ?? 'solid';
|
||||||
|
$text = trim($matches[3]);
|
||||||
|
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||||
|
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
|
||||||
|
},
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no cards found, wrap in default card
|
||||||
|
if (strpos($content, '<div class="card') === false) {
|
||||||
|
$content = '<div class="card">' . $this->markdown_to_html($content) . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic markdown to HTML conversion
|
||||||
|
*
|
||||||
|
* @param string $text
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function markdown_to_html($text) {
|
||||||
|
// Parse buttons FIRST - new [button:style](url)Text[/button] syntax
|
||||||
|
$text = preg_replace_callback(
|
||||||
|
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||||
|
function($matches) {
|
||||||
|
$style = $matches[1];
|
||||||
|
$url = $matches[2];
|
||||||
|
$btn_text = trim($matches[3]);
|
||||||
|
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||||
|
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
|
||||||
|
},
|
||||||
|
$text
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse buttons - old [button url="..."] syntax
|
||||||
|
$text = preg_replace_callback(
|
||||||
|
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=[\'"](\\w+)[\'"])?\]([^\[]+)\[\/button\]/',
|
||||||
|
function($matches) {
|
||||||
|
$url = $matches[1];
|
||||||
|
$style = $matches[2] ?? 'solid';
|
||||||
|
$btn_text = trim($matches[3]);
|
||||||
|
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||||
|
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
|
||||||
|
},
|
||||||
|
$text
|
||||||
|
);
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
$text = preg_replace('/^### (.+)$/m', '<h3>$1</h3>', $text);
|
||||||
|
$text = preg_replace('/^## (.+)$/m', '<h2>$1</h2>', $text);
|
||||||
|
$text = preg_replace('/^# (.+)$/m', '<h1>$1</h1>', $text);
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
|
||||||
|
|
||||||
|
// Italic
|
||||||
|
$text = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $text);
|
||||||
|
|
||||||
|
// Links (but not button syntax - already handled above)
|
||||||
|
$text = preg_replace('/\[(?!button)([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $text);
|
||||||
|
|
||||||
|
// List items
|
||||||
|
$text = preg_replace('/^- (.+)$/m', '<li>$1</li>', $text);
|
||||||
|
$text = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $text);
|
||||||
|
|
||||||
|
// Paragraphs - wrap lines that aren't already wrapped
|
||||||
|
$lines = explode("\n", $text);
|
||||||
|
$result = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line)) continue;
|
||||||
|
if (!preg_match('/^<(h[1-6]|ul|li|div|p|table|tr|td|th)/', $line)) {
|
||||||
|
$line = '<p>' . $line . '</p>';
|
||||||
|
}
|
||||||
|
$result[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ use WP_REST_Request;
|
|||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WooNooW\Frontend\PlaceholderRenderer;
|
use WooNooW\Frontend\PlaceholderRenderer;
|
||||||
|
|
||||||
use WooNooW\Frontend\PageSSR;
|
use WooNooW\Frontend\PageSSR;
|
||||||
|
use WooNooW\Templates\TemplateRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pages Controller
|
* Pages Controller
|
||||||
@@ -20,6 +22,13 @@ class PagesController
|
|||||||
{
|
{
|
||||||
$namespace = 'woonoow/v1';
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
|
// Unset SPA Landing (Must be before generic slug route)
|
||||||
|
register_rest_route($namespace, '/pages/unset-spa-landing', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'unset_spa_landing'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
// List all pages and templates
|
// List all pages and templates
|
||||||
register_rest_route($namespace, '/pages', [
|
register_rest_route($namespace, '/pages', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
@@ -41,6 +50,13 @@ class PagesController
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Get template presets (Must be before generic template cpt route)
|
||||||
|
register_rest_route($namespace, '/templates/presets', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_template_presets'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
|
||||||
// Get/Save CPT templates
|
// Get/Save CPT templates
|
||||||
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||||
[
|
[
|
||||||
@@ -62,6 +78,8 @@ class PagesController
|
|||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Create new page
|
// Create new page
|
||||||
register_rest_route($namespace, '/pages', [
|
register_rest_route($namespace, '/pages', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
@@ -82,6 +100,22 @@ class PagesController
|
|||||||
'callback' => [__CLASS__, 'render_template_preview'],
|
'callback' => [__CLASS__, 'render_template_preview'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Set page as SPA Landing (shown at SPA root route)
|
||||||
|
register_rest_route($namespace, '/pages/(?P<id>\d+)/set-as-spa-landing', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'set_as_spa_landing'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete page
|
||||||
|
register_rest_route($namespace, '/pages/(?P<id>\d+)', [
|
||||||
|
'methods' => 'DELETE',
|
||||||
|
'callback' => [__CLASS__, 'delete_page'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,12 +127,24 @@ class PagesController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all pages and templates
|
* Get available template presets
|
||||||
*/
|
*/
|
||||||
public static function get_pages(WP_REST_Request $request)
|
public static function get_template_presets()
|
||||||
|
{
|
||||||
|
return new WP_REST_Response(TemplateRegistry::get_templates(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all editable pages (and templates)
|
||||||
|
*/
|
||||||
|
public static function get_pages()
|
||||||
{
|
{
|
||||||
$result = [];
|
$result = [];
|
||||||
|
|
||||||
|
// Get SPA settings
|
||||||
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||||
|
|
||||||
// Get structural pages (pages with WooNooW structure)
|
// Get structural pages (pages with WooNooW structure)
|
||||||
$pages = get_posts([
|
$pages = get_posts([
|
||||||
'post_type' => 'page',
|
'post_type' => 'page',
|
||||||
@@ -119,6 +165,7 @@ class PagesController
|
|||||||
'title' => $page->post_title,
|
'title' => $page->post_title,
|
||||||
'url' => get_permalink($page),
|
'url' => get_permalink($page),
|
||||||
'icon' => 'page',
|
'icon' => 'page',
|
||||||
|
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +202,10 @@ class PagesController
|
|||||||
|
|
||||||
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||||
|
|
||||||
|
// Get SPA settings
|
||||||
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||||
|
|
||||||
// Get SEO data (Yoast/Rank Math)
|
// Get SEO data (Yoast/Rank Math)
|
||||||
$seo = self::get_seo_data($page->ID);
|
$seo = self::get_seo_data($page->ID);
|
||||||
|
|
||||||
@@ -163,8 +214,8 @@ class PagesController
|
|||||||
'type' => 'page',
|
'type' => 'page',
|
||||||
'slug' => $page->post_name,
|
'slug' => $page->post_name,
|
||||||
'title' => $page->post_title,
|
'title' => $page->post_title,
|
||||||
'url' => get_permalink($page),
|
|
||||||
'seo' => $seo,
|
'seo' => $seo,
|
||||||
|
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
|
||||||
'structure' => $structure ?: ['sections' => []],
|
'structure' => $structure ?: ['sections' => []],
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
@@ -198,6 +249,9 @@ class PagesController
|
|||||||
|
|
||||||
update_post_meta($page->ID, '_wn_page_structure', $save_data);
|
update_post_meta($page->ID, '_wn_page_structure', $save_data);
|
||||||
|
|
||||||
|
// Invalidate SSR cache
|
||||||
|
delete_transient("wn_ssr_page_{$page->ID}");
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'page' => [
|
'page' => [
|
||||||
@@ -327,6 +381,53 @@ class PagesController
|
|||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set page as SPA Landing (the page shown at SPA root route)
|
||||||
|
* This does NOT affect WordPress page_on_front setting.
|
||||||
|
*/
|
||||||
|
public static function set_as_spa_landing(WP_REST_Request $request) {
|
||||||
|
$id = (int)$request->get_param('id');
|
||||||
|
|
||||||
|
// Verify the page exists
|
||||||
|
$page = get_post($id);
|
||||||
|
if (!$page || $page->post_type !== 'page') {
|
||||||
|
return new WP_Error('invalid_page', 'Page not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update WooNooW SPA settings - set this page as the SPA frontpage
|
||||||
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
if (!isset($settings['general'])) {
|
||||||
|
$settings['general'] = [];
|
||||||
|
}
|
||||||
|
$settings['general']['spa_frontpage'] = $id;
|
||||||
|
|
||||||
|
update_option('woonoow_appearance_settings', $settings);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'id' => $id,
|
||||||
|
'message' => 'SPA Landing page set successfully'
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unset SPA Landing (the page shown at SPA root route)
|
||||||
|
* After unsetting, SPA will redirect to /shop or /checkout based on mode
|
||||||
|
*/
|
||||||
|
public static function unset_spa_landing(WP_REST_Request $request) {
|
||||||
|
// Update WooNooW SPA settings - clear the SPA frontpage
|
||||||
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
if (isset($settings['general'])) {
|
||||||
|
$settings['general']['spa_frontpage'] = 0;
|
||||||
|
}
|
||||||
|
update_option('woonoow_appearance_settings', $settings);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'SPA Landing page unset. Root will now redirect to shop/checkout.'
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new page
|
* Create new page
|
||||||
*/
|
*/
|
||||||
@@ -365,6 +466,15 @@ class PagesController
|
|||||||
'created_at' => current_time('mysql'),
|
'created_at' => current_time('mysql'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Apply template if provided
|
||||||
|
$template_id = $body['templateId'] ?? null;
|
||||||
|
if ($template_id) {
|
||||||
|
$template = TemplateRegistry::get_template($template_id);
|
||||||
|
if ($template) {
|
||||||
|
$structure['sections'] = $template['sections'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
update_post_meta($page_id, '_wn_page_structure', $structure);
|
update_post_meta($page_id, '_wn_page_structure', $structure);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
@@ -378,6 +488,42 @@ class PagesController
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete page
|
||||||
|
*/
|
||||||
|
public static function delete_page(WP_REST_Request $request) {
|
||||||
|
$id = (int)$request->get_param('id');
|
||||||
|
|
||||||
|
$page = get_post($id);
|
||||||
|
if (!$page || $page->post_type !== 'page') {
|
||||||
|
return new WP_Error('not_found', 'Page not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's the SPA front page
|
||||||
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||||
|
|
||||||
|
if ((int)$id === (int)$spa_frontpage_id) {
|
||||||
|
// Unset SPA frontpage if deleting it
|
||||||
|
if (isset($settings['general'])) {
|
||||||
|
$settings['general']['spa_frontpage'] = 0;
|
||||||
|
update_option('woonoow_appearance_settings', $settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = wp_delete_post($id, true); // Force delete
|
||||||
|
|
||||||
|
if (!$deleted) {
|
||||||
|
return new WP_Error('delete_failed', 'Failed to delete page', ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'id' => $id,
|
||||||
|
'message' => 'Page deleted successfully'
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Helper Methods
|
// Helper Methods
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
|
|||||||
*/
|
*/
|
||||||
class NavigationRegistry {
|
class NavigationRegistry {
|
||||||
const NAV_OPTION = 'wnw_nav_tree';
|
const NAV_OPTION = 'wnw_nav_tree';
|
||||||
const NAV_VERSION = '1.1.0'; // Added Pages (Page Editor)
|
const NAV_VERSION = '1.2.0'; // Added Menus (Menu Editor)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize hooks
|
* Initialize hooks
|
||||||
@@ -170,6 +170,7 @@ class NavigationRegistry {
|
|||||||
'children' => [
|
'children' => [
|
||||||
['label' => __('General', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/general'],
|
['label' => __('General', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/general'],
|
||||||
['label' => __('Pages', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/pages'],
|
['label' => __('Pages', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/pages'],
|
||||||
|
['label' => __('Menus', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/menus'],
|
||||||
['label' => __('Header', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/header'],
|
['label' => __('Header', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/header'],
|
||||||
['label' => __('Footer', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/footer'],
|
['label' => __('Footer', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/footer'],
|
||||||
['label' => __('Shop', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/shop'],
|
['label' => __('Shop', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/shop'],
|
||||||
|
|||||||
@@ -373,13 +373,15 @@ class EmailRenderer {
|
|||||||
$content = MarkdownParser::parse($content);
|
$content = MarkdownParser::parse($content);
|
||||||
|
|
||||||
// Get email customization settings for colors
|
// Get email customization settings for colors
|
||||||
$email_settings = get_option('woonoow_email_settings', []);
|
// Use unified colors from Appearance > General > Colors
|
||||||
$primary_color = $email_settings['primary_color'] ?? '#7f54b3';
|
$appearance = get_option('woonoow_appearance_settings', []);
|
||||||
$secondary_color = $email_settings['secondary_color'] ?? '#7f54b3';
|
$colors = $appearance['general']['colors'] ?? [];
|
||||||
$button_text_color = $email_settings['button_text_color'] ?? '#ffffff';
|
$primary_color = $colors['primary'] ?? '#7f54b3';
|
||||||
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea';
|
$secondary_color = $colors['secondary'] ?? '#7f54b3';
|
||||||
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
|
$button_text_color = '#ffffff'; // Always white on primary buttons
|
||||||
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff';
|
$hero_gradient_start = $colors['gradientStart'] ?? '#667eea';
|
||||||
|
$hero_gradient_end = $colors['gradientEnd'] ?? '#764ba2';
|
||||||
|
$hero_text_color = '#ffffff'; // Always white on gradient
|
||||||
|
|
||||||
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
||||||
// Helper function to generate button HTML
|
// Helper function to generate button HTML
|
||||||
|
|||||||
@@ -144,11 +144,11 @@ class Assets {
|
|||||||
$theme_settings = array_replace_recursive($default_settings, $spa_settings);
|
$theme_settings = array_replace_recursive($default_settings, $spa_settings);
|
||||||
|
|
||||||
// Get appearance settings and preload them
|
// Get appearance settings and preload them
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$stored_settings = get_option('woonoow_appearance_settings', []);
|
||||||
if (empty($appearance_settings)) {
|
$default_appearance = \WooNooW\Admin\AppearanceController::get_default_settings();
|
||||||
// Use defaults from AppearanceController
|
|
||||||
$appearance_settings = \WooNooW\Admin\AppearanceController::get_default_settings();
|
// Merge stored settings with defaults to ensure new fields (like gradient colors) exist
|
||||||
}
|
$appearance_settings = array_replace_recursive($default_appearance, $stored_settings);
|
||||||
|
|
||||||
// Get WooCommerce currency settings
|
// Get WooCommerce currency settings
|
||||||
$currency_settings = [
|
$currency_settings = [
|
||||||
@@ -198,12 +198,23 @@ class Assets {
|
|||||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
|
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
|
||||||
|
|
||||||
// Check if SPA page is set as WordPress frontpage
|
// Check if SPA Entry Page is set as WordPress frontpage
|
||||||
$frontpage_id = (int) get_option('page_on_front');
|
$frontpage_id = (int) get_option('page_on_front');
|
||||||
$is_spa_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
|
$is_spa_wp_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
|
||||||
|
|
||||||
// If SPA is frontpage, base path is /, otherwise use page slug
|
// Get SPA Landing page (explicit setting, separate from Entry Page)
|
||||||
$base_path = $is_spa_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
// This determines what content to show at the SPA root route "/"
|
||||||
|
$spa_frontpage_id = $appearance_settings['general']['spa_frontpage'] ?? 0;
|
||||||
|
$front_page_slug = '';
|
||||||
|
if ($spa_frontpage_id) {
|
||||||
|
$spa_frontpage = get_post($spa_frontpage_id);
|
||||||
|
if ($spa_frontpage) {
|
||||||
|
$front_page_slug = $spa_frontpage->post_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
|
||||||
|
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
||||||
|
|
||||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
@@ -223,6 +234,8 @@ class Assets {
|
|||||||
'appearanceSettings' => $appearance_settings,
|
'appearanceSettings' => $appearance_settings,
|
||||||
'basePath' => $base_path,
|
'basePath' => $base_path,
|
||||||
'useBrowserRouter' => $use_browser_router,
|
'useBrowserRouter' => $use_browser_router,
|
||||||
|
'frontPageSlug' => $front_page_slug,
|
||||||
|
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
||||||
];
|
];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
@@ -270,11 +283,11 @@ class Assets {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Customer SPA settings
|
// Get SPA mode from appearance settings (the correct source)
|
||||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
|
||||||
// If disabled, don't load
|
// If disabled, only load for pages with shortcodes
|
||||||
if ($mode === 'disabled') {
|
if ($mode === 'disabled') {
|
||||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||||
if (function_exists('is_shop') && is_shop()) {
|
if (function_exists('is_shop') && is_shop()) {
|
||||||
|
|||||||
@@ -49,10 +49,13 @@ class PageSSR
|
|||||||
// Generate section ID for anchor links
|
// Generate section ID for anchor links
|
||||||
$section_id = $section['id'] ?? 'section-' . uniqid();
|
$section_id = $section['id'] ?? 'section-' . uniqid();
|
||||||
|
|
||||||
|
$element_styles = $section['elementStyles'] ?? [];
|
||||||
|
$styles = $section['styles'] ?? []; // Section wrapper styles (bg, overlay)
|
||||||
|
|
||||||
// Render based on section type
|
// Render based on section type
|
||||||
$method = 'render_' . str_replace('-', '_', $type);
|
$method = 'render_' . str_replace('-', '_', $type);
|
||||||
if (method_exists(__CLASS__, $method)) {
|
if (method_exists(__CLASS__, $method)) {
|
||||||
return self::$method($resolved_props, $layout, $color_scheme, $section_id);
|
return self::$method($resolved_props, $layout, $color_scheme, $section_id, $element_styles, $styles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: generic section wrapper
|
// Fallback: generic section wrapper
|
||||||
@@ -95,10 +98,25 @@ class PageSSR
|
|||||||
// Section Renderers
|
// Section Renderers
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to generate style attribute string
|
||||||
|
*/
|
||||||
|
private static function generate_style_attr($styles) {
|
||||||
|
if (empty($styles)) return '';
|
||||||
|
|
||||||
|
$css = [];
|
||||||
|
if (!empty($styles['color'])) $css[] = "color: {$styles['color']}";
|
||||||
|
if (!empty($styles['backgroundColor'])) $css[] = "background-color: {$styles['backgroundColor']}";
|
||||||
|
if (!empty($styles['fontSize'])) $css[] = "font-size: {$styles['fontSize']}"; // Note: assumes value has unit or is handled by class, but inline style works for specific values
|
||||||
|
// Add more mapping if needed, or rely on frontend to send valid CSS values
|
||||||
|
|
||||||
|
return empty($css) ? '' : 'style="' . implode(';', $css) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render Hero section
|
* Render Hero section
|
||||||
*/
|
*/
|
||||||
public static function render_hero($props, $layout, $color_scheme, $id)
|
public static function render_hero($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||||
{
|
{
|
||||||
$title = esc_html($props['title'] ?? '');
|
$title = esc_html($props['title'] ?? '');
|
||||||
$subtitle = esc_html($props['subtitle'] ?? '');
|
$subtitle = esc_html($props['subtitle'] ?? '');
|
||||||
@@ -106,21 +124,50 @@ class PageSSR
|
|||||||
$cta_text = esc_html($props['cta_text'] ?? '');
|
$cta_text = esc_html($props['cta_text'] ?? '');
|
||||||
$cta_url = esc_url($props['cta_url'] ?? '');
|
$cta_url = esc_url($props['cta_url'] ?? '');
|
||||||
|
|
||||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\">";
|
// Section Styles (Background & Spacing)
|
||||||
|
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||||
|
$bg_image = $section_styles['backgroundImage'] ?? '';
|
||||||
|
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
|
||||||
|
$pt = $section_styles['paddingTop'] ?? '';
|
||||||
|
$pb = $section_styles['paddingBottom'] ?? '';
|
||||||
|
$height_preset = $section_styles['heightPreset'] ?? '';
|
||||||
|
|
||||||
if ($image) {
|
$section_css = "";
|
||||||
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
|
if ($bg_color) $section_css .= "background-color: {$bg_color};";
|
||||||
|
if ($bg_image) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
|
||||||
|
if ($pt) $section_css .= "padding-top: {$pt};";
|
||||||
|
if ($pb) $section_css .= "padding-bottom: {$pb};";
|
||||||
|
if ($height_preset === 'screen') $section_css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||||
|
|
||||||
|
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
|
||||||
|
|
||||||
|
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>";
|
||||||
|
|
||||||
|
// Overlay
|
||||||
|
if ($overlay_opacity > 0) {
|
||||||
|
$opacity = $overlay_opacity / 100;
|
||||||
|
$html .= "<div class=\"wn-hero__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
$html .= '<div class="wn-hero__content">';
|
// Element Styles
|
||||||
|
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||||
|
$subtitle_style = self::generate_style_attr($element_styles['subtitle'] ?? []);
|
||||||
|
$cta_style = self::generate_style_attr($element_styles['cta_text'] ?? []); // Button
|
||||||
|
|
||||||
|
// Image (if not background)
|
||||||
|
if ($image && !$bg_image && $layout !== 'default') {
|
||||||
|
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<div class="wn-hero__content" style="position: relative; z-index: 10;">';
|
||||||
if ($title) {
|
if ($title) {
|
||||||
$html .= "<h1 class=\"wn-hero__title\">{$title}</h1>";
|
$html .= "<h1 class=\"wn-hero__title\" {$title_style}>{$title}</h1>";
|
||||||
}
|
}
|
||||||
if ($subtitle) {
|
if ($subtitle) {
|
||||||
$html .= "<p class=\"wn-hero__subtitle\">{$subtitle}</p>";
|
$html .= "<p class=\"wn-hero__subtitle\" {$subtitle_style}>{$subtitle}</p>";
|
||||||
}
|
}
|
||||||
if ($cta_text && $cta_url) {
|
if ($cta_text && $cta_url) {
|
||||||
$html .= "<a href=\"{$cta_url}\" class=\"wn-hero__cta\">{$cta_text}</a>";
|
$html .= "<a href=\"{$cta_url}\" class=\"wn-hero__cta\" {$cta_style}>{$cta_text}</a>";
|
||||||
}
|
}
|
||||||
$html .= '</div>';
|
$html .= '</div>';
|
||||||
$html .= '</section>';
|
$html .= '</section>';
|
||||||
@@ -128,50 +175,154 @@ class PageSSR
|
|||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal Row Renderer (Shared logic for Content & ImageText)
|
||||||
|
*/
|
||||||
|
private static function render_universal_row($props, $layout, $color_scheme, $element_styles, $options = []) {
|
||||||
|
$title = esc_html($props['title']['value'] ?? ($props['title'] ?? ''));
|
||||||
|
$text = $props['text']['value'] ?? ($props['text'] ?? ($props['content']['value'] ?? ($props['content'] ?? ''))); // Handle both props/values
|
||||||
|
$image = esc_url($props['image']['value'] ?? ($props['image'] ?? ''));
|
||||||
|
|
||||||
|
// Options
|
||||||
|
$has_image = !empty($image);
|
||||||
|
$image_pos = $layout ?: 'left';
|
||||||
|
|
||||||
|
// Element Styles
|
||||||
|
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||||
|
$text_style = self::generate_style_attr($element_styles['text'] ?? ($element_styles['content'] ?? []));
|
||||||
|
|
||||||
|
// Wrapper Classes
|
||||||
|
$wrapper_class = "wn-max-w-7xl wn-mx-auto wn-px-4";
|
||||||
|
$grid_class = "wn-mx-auto";
|
||||||
|
|
||||||
|
if ($has_image && in_array($image_pos, ['left', 'right', 'image-left', 'image-right'])) {
|
||||||
|
$grid_class .= " wn-grid wn-grid-cols-1 wn-lg-grid-cols-2 wn-gap-12 wn-items-center";
|
||||||
|
} else {
|
||||||
|
$grid_class .= " wn-max-w-4xl";
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = "<div class=\"{$wrapper_class}\">";
|
||||||
|
$html .= "<div class=\"{$grid_class}\">";
|
||||||
|
|
||||||
|
// Image Output
|
||||||
|
$image_html = "";
|
||||||
|
if ($current_pos_right = ($image_pos === 'right' || $image_pos === 'image-right')) {
|
||||||
|
$order_class = 'wn-lg-order-last';
|
||||||
|
} else {
|
||||||
|
$order_class = 'wn-lg-order-first';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($has_image) {
|
||||||
|
$image_html = "<div class=\"wn-relative wn-w-full wn-aspect-[4/3] wn-rounded-2xl wn-overflow-hidden wn-shadow-lg {$order_class}\">";
|
||||||
|
$image_html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-absolute wn-inset-0 wn-w-full wn-h-full wn-object-cover\" />";
|
||||||
|
$image_html .= "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content Output
|
||||||
|
$content_html = "<div class=\"wn-flex wn-flex-col\">";
|
||||||
|
if ($title) {
|
||||||
|
$content_html .= "<h2 class=\"wn-text-3xl wn-font-bold wn-mb-6\" {$title_style}>{$title}</h2>";
|
||||||
|
}
|
||||||
|
if ($text) {
|
||||||
|
// Apply prose classes similar to React
|
||||||
|
$content_html .= "<div class=\"wn-prose wn-prose-lg wn-max-w-none\" {$text_style}>{$text}</div>";
|
||||||
|
}
|
||||||
|
$content_html .= "</div>";
|
||||||
|
|
||||||
|
// Render based on order (Grid handles order via CSS classes for left/right, but fallback for DOM order)
|
||||||
|
if ($has_image) {
|
||||||
|
// For grid layout, we output both. CSS order handles visual.
|
||||||
|
$html .= $image_html . $content_html;
|
||||||
|
} else {
|
||||||
|
$html .= $content_html;
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= "</div></div>";
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render Content section (for post body, rich text)
|
* Render Content section (for post body, rich text)
|
||||||
*/
|
*/
|
||||||
public static function render_content($props, $layout, $color_scheme, $id)
|
public static function render_content($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||||
{
|
{
|
||||||
$content = $props['content'] ?? '';
|
$content = $props['content'] ?? '';
|
||||||
// Apply WordPress content filters (shortcodes, autop, etc.)
|
// Apply WordPress content filters (shortcodes, autop, etc.)
|
||||||
$content = apply_filters('the_content', $content);
|
$content = apply_filters('the_content', $content);
|
||||||
|
// Normalize prop structure for universal renderer if needed
|
||||||
|
if (is_string($props['content'])) {
|
||||||
|
$props['content'] = ['value' => $content];
|
||||||
|
} else {
|
||||||
|
$props['content']['value'] = $content;
|
||||||
|
}
|
||||||
|
|
||||||
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\">{$content}</section>";
|
// Section Styles (Background)
|
||||||
|
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||||
|
$padding = $section_styles['paddingTop'] ?? '';
|
||||||
|
$height_preset = $section_styles['heightPreset'] ?? '';
|
||||||
|
|
||||||
|
$css = "";
|
||||||
|
if($bg_color) $css .= "background-color:{$bg_color};";
|
||||||
|
|
||||||
|
// Height Logic
|
||||||
|
if ($height_preset === 'screen') {
|
||||||
|
$css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||||
|
$padding = '5rem'; // Default padding for screen to avoid edge collision
|
||||||
|
} elseif ($height_preset === 'small') {
|
||||||
|
$padding = '2rem';
|
||||||
|
} elseif ($height_preset === 'large') {
|
||||||
|
$padding = '8rem';
|
||||||
|
} elseif ($height_preset === 'medium') {
|
||||||
|
$padding = '4rem';
|
||||||
|
}
|
||||||
|
|
||||||
|
if($padding) $css .= "padding:{$padding} 0;";
|
||||||
|
|
||||||
|
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||||
|
|
||||||
|
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles);
|
||||||
|
|
||||||
|
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render Image + Text section
|
* Render Image + Text section
|
||||||
*/
|
*/
|
||||||
public static function render_image_text($props, $layout, $color_scheme, $id)
|
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||||
{
|
{
|
||||||
$title = esc_html($props['title'] ?? '');
|
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||||
$text = wp_kses_post($props['text'] ?? '');
|
$padding = $section_styles['paddingTop'] ?? '';
|
||||||
$image = esc_url($props['image'] ?? '');
|
$height_preset = $section_styles['heightPreset'] ?? '';
|
||||||
|
|
||||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-image-text--{$layout} wn-scheme--{$color_scheme}\">";
|
$css = "";
|
||||||
|
if($bg_color) $css .= "background-color:{$bg_color};";
|
||||||
|
|
||||||
if ($image) {
|
// Height Logic
|
||||||
$html .= "<div class=\"wn-image-text__image\"><img src=\"{$image}\" alt=\"{$title}\" /></div>";
|
if ($height_preset === 'screen') {
|
||||||
|
$css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||||
|
$padding = '5rem';
|
||||||
|
} elseif ($height_preset === 'small') {
|
||||||
|
$padding = '2rem';
|
||||||
|
} elseif ($height_preset === 'large') {
|
||||||
|
$padding = '8rem';
|
||||||
|
} elseif ($height_preset === 'medium') {
|
||||||
|
$padding = '4rem';
|
||||||
}
|
}
|
||||||
|
|
||||||
$html .= '<div class="wn-image-text__content">';
|
if($padding) $css .= "padding:{$padding} 0;";
|
||||||
if ($title) {
|
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||||
$html .= "<h2 class=\"wn-image-text__title\">{$title}</h2>";
|
|
||||||
}
|
|
||||||
if ($text) {
|
|
||||||
$html .= "<div class=\"wn-image-text__text\">{$text}</div>";
|
|
||||||
}
|
|
||||||
$html .= '</div>';
|
|
||||||
$html .= '</section>';
|
|
||||||
|
|
||||||
return $html;
|
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles);
|
||||||
|
|
||||||
|
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render Feature Grid section
|
* Render Feature Grid section
|
||||||
*/
|
*/
|
||||||
public static function render_feature_grid($props, $layout, $color_scheme, $id)
|
public static function render_feature_grid($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||||
{
|
{
|
||||||
$heading = esc_html($props['heading'] ?? '');
|
$heading = esc_html($props['heading'] ?? '');
|
||||||
$items = $props['items'] ?? [];
|
$items = $props['items'] ?? [];
|
||||||
@@ -182,21 +333,36 @@ class PageSSR
|
|||||||
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
|
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feature Item Styles (Card)
|
||||||
|
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here
|
||||||
|
$item_bg = $element_styles['feature_item']['backgroundColor'] ?? '';
|
||||||
|
|
||||||
$html .= '<div class="wn-feature-grid__items">';
|
$html .= '<div class="wn-feature-grid__items">';
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$item_title = esc_html($item['title'] ?? '');
|
$item_title = esc_html($item['title'] ?? '');
|
||||||
$item_desc = esc_html($item['description'] ?? '');
|
$item_desc = esc_html($item['description'] ?? '');
|
||||||
$item_icon = esc_html($item['icon'] ?? '');
|
$item_icon = esc_html($item['icon'] ?? '');
|
||||||
|
|
||||||
$html .= '<div class="wn-feature-grid__item">';
|
// Allow overriding item specific style if needed, but for now global
|
||||||
|
$html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>";
|
||||||
|
|
||||||
|
// Render Icon SVG
|
||||||
if ($item_icon) {
|
if ($item_icon) {
|
||||||
$html .= "<span class=\"wn-feature-grid__icon\">{$item_icon}</span>";
|
$icon_svg = self::get_icon_svg($item_icon);
|
||||||
|
if ($icon_svg) {
|
||||||
|
$html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($item_title) {
|
if ($item_title) {
|
||||||
$html .= "<h3 class=\"wn-feature-grid__item-title\">{$item_title}</h3>";
|
// Feature title style
|
||||||
|
$f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []);
|
||||||
|
$html .= "<h3 class=\"wn-feature-grid__item-title\" {$f_title_style}>{$item_title}</h3>";
|
||||||
}
|
}
|
||||||
if ($item_desc) {
|
if ($item_desc) {
|
||||||
$html .= "<p class=\"wn-feature-grid__item-desc\">{$item_desc}</p>";
|
// Feature description style
|
||||||
|
$f_desc_style = self::generate_style_attr($element_styles['feature_description'] ?? []);
|
||||||
|
$html .= "<p class=\"wn-feature-grid__item-desc\" {$f_desc_style}>{$item_desc}</p>";
|
||||||
}
|
}
|
||||||
$html .= '</div>';
|
$html .= '</div>';
|
||||||
}
|
}
|
||||||
@@ -209,7 +375,7 @@ class PageSSR
|
|||||||
/**
|
/**
|
||||||
* Render CTA Banner section
|
* Render CTA Banner section
|
||||||
*/
|
*/
|
||||||
public static function render_cta_banner($props, $layout, $color_scheme, $id)
|
public static function render_cta_banner($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||||
{
|
{
|
||||||
$title = esc_html($props['title'] ?? '');
|
$title = esc_html($props['title'] ?? '');
|
||||||
$text = esc_html($props['text'] ?? '');
|
$text = esc_html($props['text'] ?? '');
|
||||||
@@ -238,13 +404,29 @@ class PageSSR
|
|||||||
/**
|
/**
|
||||||
* Render Contact Form section
|
* Render Contact Form section
|
||||||
*/
|
*/
|
||||||
public static function render_contact_form($props, $layout, $color_scheme, $id)
|
public static function render_contact_form($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||||
{
|
{
|
||||||
$title = esc_html($props['title'] ?? '');
|
$title = esc_html($props['title'] ?? '');
|
||||||
$webhook_url = esc_url($props['webhook_url'] ?? '');
|
$webhook_url = esc_url($props['webhook_url'] ?? '');
|
||||||
$redirect_url = esc_url($props['redirect_url'] ?? '');
|
$redirect_url = esc_url($props['redirect_url'] ?? '');
|
||||||
$fields = $props['fields'] ?? ['name', 'email', 'message'];
|
$fields = $props['fields'] ?? ['name', 'email', 'message'];
|
||||||
|
|
||||||
|
// Extract styles
|
||||||
|
$btn_bg = $element_styles['button']['backgroundColor'] ?? '';
|
||||||
|
$btn_color = $element_styles['button']['color'] ?? '';
|
||||||
|
$field_bg = $element_styles['fields']['backgroundColor'] ?? '';
|
||||||
|
$field_color = $element_styles['fields']['color'] ?? '';
|
||||||
|
|
||||||
|
$btn_style = "";
|
||||||
|
if ($btn_bg) $btn_style .= "background-color: {$btn_bg};";
|
||||||
|
if ($btn_color) $btn_style .= "color: {$btn_color};";
|
||||||
|
$btn_attr = $btn_style ? "style=\"{$btn_style}\"" : "";
|
||||||
|
|
||||||
|
$field_style = "";
|
||||||
|
if ($field_bg) $field_style .= "background-color: {$field_bg};";
|
||||||
|
if ($field_color) $field_style .= "color: {$field_color};";
|
||||||
|
$field_attr = $field_style ? "style=\"{$field_style}\"" : "";
|
||||||
|
|
||||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
|
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
|
||||||
|
|
||||||
if ($title) {
|
if ($title) {
|
||||||
@@ -259,20 +441,39 @@ class PageSSR
|
|||||||
$html .= '<div class="wn-contact-form__field">';
|
$html .= '<div class="wn-contact-form__field">';
|
||||||
$html .= "<label>{$field_label}</label>";
|
$html .= "<label>{$field_label}</label>";
|
||||||
if ($field === 'message') {
|
if ($field === 'message') {
|
||||||
$html .= "<textarea name=\"{$field}\" placeholder=\"{$field_label}\"></textarea>";
|
$html .= "<textarea name=\"{$field}\" placeholder=\"{$field_label}\" {$field_attr}></textarea>";
|
||||||
} else {
|
} else {
|
||||||
$html .= "<input type=\"text\" name=\"{$field}\" placeholder=\"{$field_label}\" />";
|
$html .= "<input type=\"text\" name=\"{$field}\" placeholder=\"{$field_label}\" {$field_attr} />";
|
||||||
}
|
}
|
||||||
$html .= '</div>';
|
$html .= '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
$html .= '<button type="submit">Submit</button>';
|
$html .= "<button type=\"submit\" {$btn_attr}>Submit</button>";
|
||||||
$html .= '</form>';
|
$html .= '</form>';
|
||||||
$html .= '</section>';
|
$html .= '</section>';
|
||||||
|
|
||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get SVG for known icons
|
||||||
|
*/
|
||||||
|
private static function get_icon_svg($name) {
|
||||||
|
$icons = [
|
||||||
|
'Star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
|
||||||
|
'Zap' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||||
|
'Shield' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
|
||||||
|
'Heart' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
|
||||||
|
'Award' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>',
|
||||||
|
'Clock' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||||
|
'Truck' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>',
|
||||||
|
'User' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
||||||
|
'Settings' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $icons[$name] ?? $icons['Star'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic section fallback
|
* Generic section fallback
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -166,7 +166,14 @@ class TemplateOverride
|
|||||||
'top'
|
'top'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Rewrite /slug/anything to serve the SPA page
|
// Rewrite /slug to serve the SPA page (base URL)
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^' . preg_quote($spa_slug, '/') . '/?$',
|
||||||
|
'index.php?page_id=' . $spa_page_id,
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rewrite /slug/anything to serve the SPA page with path
|
||||||
// React Router handles the path after that
|
// React Router handles the path after that
|
||||||
add_rewrite_rule(
|
add_rewrite_rule(
|
||||||
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
|
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
|
||||||
@@ -306,8 +313,30 @@ class TemplateOverride
|
|||||||
wp_redirect($build_route('my-account'), 302);
|
wp_redirect($build_route('my-account'), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect structural pages with WooNooW sections to SPA
|
||||||
|
if (is_singular('page') && $post) {
|
||||||
|
// Skip the SPA page itself and frontpage
|
||||||
|
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if page has WooNooW structure
|
||||||
|
$structure = get_post_meta($post->ID, '_wn_page_structure', true);
|
||||||
|
if (!empty($structure) && !empty($structure['sections'])) {
|
||||||
|
// Redirect to SPA with page slug route
|
||||||
|
$page_slug = $post->post_name;
|
||||||
|
wp_redirect($build_route($page_slug), 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve SPA template directly for frontpage SPA routes
|
||||||
|
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||||
|
* and serve the SPA template directly (bypasses WooCommerce templates)
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Serve SPA template directly for frontpage SPA routes
|
* Serve SPA template directly for frontpage SPA routes
|
||||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||||
@@ -331,8 +360,19 @@ class TemplateOverride
|
|||||||
return; // SPA is not frontpage, let normal routing handle it
|
return; // SPA is not frontpage, let normal routing handle it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current request path
|
// Get the current request path relative to site root
|
||||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$home_path = parse_url(home_url(), PHP_URL_PATH);
|
||||||
|
|
||||||
|
// Normalize request URI for subdirectory installs
|
||||||
|
if ($home_path && $home_path !== '/') {
|
||||||
|
$home_path = rtrim($home_path, '/');
|
||||||
|
if (strpos($request_uri, $home_path) === 0) {
|
||||||
|
$request_uri = substr($request_uri, strlen($home_path));
|
||||||
|
if (empty($request_uri)) $request_uri = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$path = parse_url($request_uri, PHP_URL_PATH);
|
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||||
$path = '/' . trim($path, '/');
|
$path = '/' . trim($path, '/');
|
||||||
|
|
||||||
@@ -365,6 +405,27 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for structural pages with WooNooW sections
|
||||||
|
if (!$should_serve_spa && !empty($path) && $path !== '/') {
|
||||||
|
// Try to find a page by slug matching the path
|
||||||
|
$slug = trim($path, '/');
|
||||||
|
|
||||||
|
// Handle nested slugs (get the last part as the page slug)
|
||||||
|
if (strpos($slug, '/') !== false) {
|
||||||
|
$slug_parts = explode('/', $slug);
|
||||||
|
$slug = end($slug_parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = get_page_by_path($slug);
|
||||||
|
if ($page) {
|
||||||
|
// Check if this page has WooNooW structure
|
||||||
|
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||||
|
if (!empty($structure) && !empty($structure['sections'])) {
|
||||||
|
$should_serve_spa = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Not a SPA route
|
// Not a SPA route
|
||||||
if (!$should_serve_spa) {
|
if (!$should_serve_spa) {
|
||||||
return;
|
return;
|
||||||
@@ -396,8 +457,8 @@ class TemplateOverride
|
|||||||
*/
|
*/
|
||||||
public static function disable_canonical_redirect($redirect_url, $requested_url)
|
public static function disable_canonical_redirect($redirect_url, $requested_url)
|
||||||
{
|
{
|
||||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
$mode = isset($settings['general']['spa_mode']) ? $settings['general']['spa_mode'] : 'disabled';
|
||||||
|
|
||||||
// Only disable redirects in full SPA mode
|
// Only disable redirects in full SPA mode
|
||||||
if ($mode !== 'full') {
|
if ($mode !== 'full') {
|
||||||
@@ -405,6 +466,7 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a SPA route
|
// Check if this is a SPA route
|
||||||
|
// We include /product/ and standard endpoints
|
||||||
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
|
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
|
||||||
|
|
||||||
foreach ($spa_routes as $route) {
|
foreach ($spa_routes as $route) {
|
||||||
@@ -733,6 +795,20 @@ class TemplateOverride
|
|||||||
*/
|
*/
|
||||||
public static function serve_ssr_content($page_id, $type = 'page', $post_obj = null)
|
public static function serve_ssr_content($page_id, $type = 'page', $post_obj = null)
|
||||||
{
|
{
|
||||||
|
// Generate cache key
|
||||||
|
$cache_id = $post_obj ? $post_obj->ID : $page_id;
|
||||||
|
$cache_key = "wn_ssr_{$type}_{$cache_id}";
|
||||||
|
|
||||||
|
// Check cache TTL (default 1 hour, filterable)
|
||||||
|
$cache_ttl = apply_filters('woonoow_ssr_cache_ttl', HOUR_IN_SECONDS);
|
||||||
|
|
||||||
|
// Try to get cached content
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
if ($cached !== false) {
|
||||||
|
echo $cached;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Get page structure
|
// Get page structure
|
||||||
if ($type === 'page') {
|
if ($type === 'page') {
|
||||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||||
@@ -783,7 +859,8 @@ class TemplateOverride
|
|||||||
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
|
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output SSR HTML
|
// Output SSR HTML - start output buffering for caching
|
||||||
|
ob_start();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html <?php language_attributes(); ?>>
|
<html <?php language_attributes(); ?>>
|
||||||
@@ -825,6 +902,14 @@ class TemplateOverride
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<?php
|
<?php
|
||||||
|
// Get buffered output
|
||||||
|
$output = ob_get_clean();
|
||||||
|
|
||||||
|
// Cache the output for bots (uses cache TTL from filter)
|
||||||
|
set_transient($cache_key, $output, $cache_ttl);
|
||||||
|
|
||||||
|
// Output and exit
|
||||||
|
echo $output;
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
373
includes/Setup/DefaultPages.php
Normal file
373
includes/Setup/DefaultPages.php
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<?php
|
||||||
|
namespace WooNooW\Setup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Pages Setup
|
||||||
|
* Creates default pages with WooNooW structure on plugin activation
|
||||||
|
*/
|
||||||
|
class DefaultPages
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure all default pages exist
|
||||||
|
*/
|
||||||
|
public static function create_pages()
|
||||||
|
{
|
||||||
|
self::create_home_page();
|
||||||
|
self::create_about_page();
|
||||||
|
self::create_contact_page();
|
||||||
|
self::create_legal_pages();
|
||||||
|
self::create_woocommerce_pages();
|
||||||
|
self::ensure_spa_settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure SPA Settings are configured
|
||||||
|
*/
|
||||||
|
private static function ensure_spa_settings()
|
||||||
|
{
|
||||||
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
|
||||||
|
// Ensure General array exists
|
||||||
|
if (!isset($settings['general'])) {
|
||||||
|
$settings['general'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable SPA mode if not set
|
||||||
|
if (empty($settings['general']['spa_mode']) || $settings['general']['spa_mode'] === 'disabled') {
|
||||||
|
$settings['general']['spa_mode'] = 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SPA Root Page if missing (prioritize Home, then Shop)
|
||||||
|
if (empty($settings['general']['spa_page'])) {
|
||||||
|
$home_page = get_page_by_path('home');
|
||||||
|
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||||
|
|
||||||
|
if ($home_page) {
|
||||||
|
// If Home exists, make it the Front Page AND SPA Root
|
||||||
|
$settings['general']['spa_page'] = $home_page->ID;
|
||||||
|
update_option('show_on_front', 'page');
|
||||||
|
update_option('page_on_front', $home_page->ID);
|
||||||
|
} elseif ($shop_page_id) {
|
||||||
|
// Fallback to Shop
|
||||||
|
$settings['general']['spa_page'] = $shop_page_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option('woonoow_appearance_settings', $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Home Page with Rich Layout
|
||||||
|
*/
|
||||||
|
private static function create_home_page()
|
||||||
|
{
|
||||||
|
$slug = 'home';
|
||||||
|
$title = 'Home';
|
||||||
|
|
||||||
|
if (self::page_exists($slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => [
|
||||||
|
[
|
||||||
|
'id' => 'section-hero-home',
|
||||||
|
'type' => 'hero',
|
||||||
|
'layoutVariant' => 'default',
|
||||||
|
'colorScheme' => 'primary',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Welcome onto WooNooW'],
|
||||||
|
'subtitle' => ['type' => 'static', 'value' => 'Discover our premium collection of products tailored just for you. Quality meets style in every item.'],
|
||||||
|
'cta_text' => ['type' => 'static', 'value' => 'Shop Now'],
|
||||||
|
'cta_url' => ['type' => 'static', 'value' => '/shop'],
|
||||||
|
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=1600&q=80'],
|
||||||
|
],
|
||||||
|
'elementStyles' => [
|
||||||
|
'title' => ['fontSize' => 'text-5xl', 'fontWeight' => 'font-bold'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'section-features-home',
|
||||||
|
'type' => 'feature-grid',
|
||||||
|
'layoutVariant' => 'grid-3',
|
||||||
|
'props' => [
|
||||||
|
'heading' => ['type' => 'static', 'value' => 'Why Choose Us'],
|
||||||
|
'features' => ['type' => 'static', 'value' => json_encode([
|
||||||
|
['icon' => 'truck', 'title' => 'Free Shipping', 'description' => 'On all orders over $50'],
|
||||||
|
['icon' => 'shield', 'title' => 'Secure Payment', 'description' => '100% secure payment processing'],
|
||||||
|
['icon' => 'clock', 'title' => '24/7 Support', 'description' => 'Dedicated support anytime you need'],
|
||||||
|
])]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'section-story-home',
|
||||||
|
'type' => 'image-text',
|
||||||
|
'layoutVariant' => 'image-left',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Our Story'],
|
||||||
|
'text' => ['type' => 'static', 'value' => 'Founded with a passion for quality and design, we strive to bring you products that elevate your everyday life. Every item is carefully curated and inspected to ensure it meets our high standards.'],
|
||||||
|
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&q=80'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'section-cta-home',
|
||||||
|
'type' => 'cta-banner',
|
||||||
|
'colorScheme' => 'muted',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Ready to start shopping?'],
|
||||||
|
'text' => ['type' => 'static', 'value' => 'Join thousands of satisfied customers today.'],
|
||||||
|
'button_text' => ['type' => 'static', 'value' => 'View Catalog'],
|
||||||
|
'button_url' => ['type' => 'static', 'value' => '/shop'],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
self::insert_page($title, $slug, $structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create About Us Page
|
||||||
|
*/
|
||||||
|
private static function create_about_page()
|
||||||
|
{
|
||||||
|
$slug = 'about';
|
||||||
|
$title = 'About Us';
|
||||||
|
|
||||||
|
if (self::page_exists($slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => [
|
||||||
|
[
|
||||||
|
'id' => 'section-hero-about',
|
||||||
|
'type' => 'hero',
|
||||||
|
'layoutVariant' => 'centered',
|
||||||
|
'colorScheme' => 'secondary',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'About Us'],
|
||||||
|
'subtitle' => ['type' => 'static', 'value' => 'Learn more about our journey and mission.'],
|
||||||
|
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1600&q=80'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'section-story-about',
|
||||||
|
'type' => 'image-text',
|
||||||
|
'layoutVariant' => 'image-right',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Who We Are'],
|
||||||
|
'text' => ['type' => 'static', 'value' => 'We are a team of passionate individuals dedicated to providing the best shopping experience. Our mission is to make quality products accessible to everyone.'],
|
||||||
|
'image' => ['type' => 'static', 'value' => 'https://images.unsplash.com/photo-1556761175-5973dc0f32e7?w=800&q=80'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'section-values-about',
|
||||||
|
'type' => 'feature-grid',
|
||||||
|
'layoutVariant' => 'grid-3',
|
||||||
|
'props' => [
|
||||||
|
'heading' => ['type' => 'static', 'value' => 'Our Core Values'],
|
||||||
|
'features' => ['type' => 'static', 'value' => json_encode([
|
||||||
|
['icon' => 'heart', 'title' => 'Passion', 'description' => 'We love what we do'],
|
||||||
|
['icon' => 'star', 'title' => 'Excellence', 'description' => 'We aim for the best'],
|
||||||
|
['icon' => 'users', 'title' => 'Community', 'description' => 'We build relationships'],
|
||||||
|
])]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
self::insert_page($title, $slug, $structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Contact Page
|
||||||
|
*/
|
||||||
|
private static function create_contact_page()
|
||||||
|
{
|
||||||
|
$slug = 'contact';
|
||||||
|
$title = 'Contact Us';
|
||||||
|
|
||||||
|
if (self::page_exists($slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => [
|
||||||
|
[
|
||||||
|
'id' => 'section-hero-contact',
|
||||||
|
'type' => 'hero',
|
||||||
|
'layoutVariant' => 'default',
|
||||||
|
'colorScheme' => 'gradient',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Get in Touch'],
|
||||||
|
'subtitle' => ['type' => 'static', 'value' => 'Have questions? We are here to help.'],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'section-form-contact',
|
||||||
|
'type' => 'contact-form',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Send us a Message'],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
self::insert_page($title, $slug, $structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Legal Pages
|
||||||
|
*/
|
||||||
|
private static function create_legal_pages()
|
||||||
|
{
|
||||||
|
$pages = [
|
||||||
|
'privacy-policy' => 'Privacy Policy',
|
||||||
|
'terms-conditions' => 'Terms & Conditions',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($pages as $slug => $title) {
|
||||||
|
if (self::page_exists($slug)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = "<h2>{$title}</h2><p>This is a placeholder for your {$title}. Please update this content with your actual legal text.</p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>";
|
||||||
|
|
||||||
|
$structure = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => [
|
||||||
|
[
|
||||||
|
'id' => 'section-content-' . $slug,
|
||||||
|
'type' => 'content',
|
||||||
|
'layoutVariant' => 'narrow',
|
||||||
|
'props' => [
|
||||||
|
'content' => ['type' => 'static', 'value' => $content],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
|
||||||
|
self::insert_page($title, $slug, $structure);
|
||||||
|
|
||||||
|
// If privacy policy, link it in WP settings
|
||||||
|
if ($slug === 'privacy-policy') {
|
||||||
|
$page = get_page_by_path($slug);
|
||||||
|
if ($page) {
|
||||||
|
update_option('wp_page_for_privacy_policy', $page->ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create WooCommerce Pages (Shop, Cart, Checkout, My Account)
|
||||||
|
*/
|
||||||
|
private static function create_woocommerce_pages()
|
||||||
|
{
|
||||||
|
if (!class_exists('WooCommerce')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wc_pages = [
|
||||||
|
'shop' => ['title' => 'Shop', 'content' => '', 'option' => 'woocommerce_shop_page_id'],
|
||||||
|
'cart' => ['title' => 'Cart', 'content' => '<!-- wp:shortcode -->[woocommerce_cart]<!-- /wp:shortcode -->', 'option' => 'woocommerce_cart_page_id'],
|
||||||
|
'checkout' => ['title' => 'Checkout', 'content' => '<!-- wp:shortcode -->[woocommerce_checkout]<!-- /wp:shortcode -->', 'option' => 'woocommerce_checkout_page_id'],
|
||||||
|
'my-account' => ['title' => 'My Account', 'content' => '<!-- wp:shortcode -->[woocommerce_my_account]<!-- /wp:shortcode -->', 'option' => 'woocommerce_myaccount_page_id'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($wc_pages as $slug => $data) {
|
||||||
|
// Check if page is already assigned in WC options
|
||||||
|
$existing_id = get_option($data['option']);
|
||||||
|
if ($existing_id && get_post($existing_id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if page exists by slug
|
||||||
|
if (self::page_exists($slug)) {
|
||||||
|
$page = get_page_by_path($slug);
|
||||||
|
update_option($data['option'], $page->ID);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create page
|
||||||
|
$page_id = wp_insert_post([
|
||||||
|
'post_title' => $data['title'],
|
||||||
|
'post_name' => $slug,
|
||||||
|
'post_content' => $data['content'],
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_type' => 'page',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($page_id && !is_wp_error($page_id)) {
|
||||||
|
update_option($data['option'], $page_id);
|
||||||
|
|
||||||
|
// For Shop page, add a fallback structure (though content-product takes precedence in SPA)
|
||||||
|
if ($slug === 'shop') {
|
||||||
|
$structure = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => [
|
||||||
|
[
|
||||||
|
'id' => 'section-shop-products',
|
||||||
|
'type' => 'content',
|
||||||
|
'props' => [
|
||||||
|
'content' => ['type' => 'static', 'value' => '[products limit="12" columns="4"]'],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
update_post_meta($page_id, '_wn_page_structure', $structure);
|
||||||
|
} else {
|
||||||
|
// For other pages, add structre that wraps the shortcode for SPA rendering
|
||||||
|
$structure = [
|
||||||
|
'type' => 'page',
|
||||||
|
'sections' => [
|
||||||
|
[
|
||||||
|
'id' => 'section-' . $slug,
|
||||||
|
'type' => 'content',
|
||||||
|
'props' => [
|
||||||
|
'content' => ['type' => 'static', 'value' => $data['content']],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'created_at' => current_time('mysql'),
|
||||||
|
];
|
||||||
|
update_post_meta($page_id, '_wn_page_structure', $structure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Check if page exists
|
||||||
|
*/
|
||||||
|
private static function page_exists($slug)
|
||||||
|
{
|
||||||
|
return !empty(get_page_by_path($slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Insert Page and Structure
|
||||||
|
*/
|
||||||
|
private static function insert_page($title, $slug, $structure)
|
||||||
|
{
|
||||||
|
$page_id = wp_insert_post([
|
||||||
|
'post_title' => $title,
|
||||||
|
'post_name' => $slug,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_type' => 'page',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($page_id && !is_wp_error($page_id)) {
|
||||||
|
update_post_meta($page_id, '_wn_page_structure', $structure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
includes/Templates/TemplateRegistry.php
Normal file
169
includes/Templates/TemplateRegistry.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WooNooW\Templates;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
class TemplateRegistry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all available templates
|
||||||
|
*/
|
||||||
|
public static function get_templates()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'blank',
|
||||||
|
'label' => 'Blank Page',
|
||||||
|
'description' => 'Start from scratch with an empty page.',
|
||||||
|
'icon' => 'file',
|
||||||
|
'sections' => []
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'landing-page',
|
||||||
|
'label' => 'Landing Page',
|
||||||
|
'description' => 'High-converting landing page with Hero, Features, and CTA.',
|
||||||
|
'icon' => 'layout',
|
||||||
|
'sections' => self::get_landing_page_structure()
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'about-us',
|
||||||
|
'label' => 'About Us',
|
||||||
|
'description' => 'Tell your story with image-text layouts and content.',
|
||||||
|
'icon' => 'users',
|
||||||
|
'sections' => self::get_about_us_structure()
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'contact',
|
||||||
|
'label' => 'Contact',
|
||||||
|
'description' => 'Simple contact page with a form and address details.',
|
||||||
|
'icon' => 'mail',
|
||||||
|
'sections' => self::get_contact_structure()
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific template by ID
|
||||||
|
*/
|
||||||
|
public static function get_template($id)
|
||||||
|
{
|
||||||
|
$templates = self::get_templates();
|
||||||
|
foreach ($templates as $template) {
|
||||||
|
if ($template['id'] === $id) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to generate a unique ID
|
||||||
|
*/
|
||||||
|
private static function generate_id()
|
||||||
|
{
|
||||||
|
return uniqid('section_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function get_landing_page_structure()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'hero',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Welcome to Our Service'],
|
||||||
|
'subtitle' => ['type' => 'static', 'value' => 'We create amazing digital experiences for your business.'],
|
||||||
|
'cta_text' => ['type' => 'static', 'value' => 'Get Started'],
|
||||||
|
'cta_url' => ['type' => 'static', 'value' => '#'],
|
||||||
|
'image' => ['type' => 'static', 'value' => ''],
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'full']
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'feature-grid',
|
||||||
|
'props' => [
|
||||||
|
'heading' => ['type' => 'static', 'value' => 'Why Choose Us'],
|
||||||
|
'features' => ['type' => 'static', 'value' => [
|
||||||
|
['title' => 'Fast Delivery', 'description' => 'Quick shipping to your doorstep'],
|
||||||
|
['title' => 'Secure Payment', 'description' => 'Your data is always protected'],
|
||||||
|
['title' => 'Quality Products', 'description' => 'Only the best for our customers']
|
||||||
|
]]
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'contained']
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'cta-banner',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Ready to Launch?'],
|
||||||
|
'text' => ['type' => 'static', 'value' => 'Join thousands of satisfied customers today.'],
|
||||||
|
'button_text' => ['type' => 'static', 'value' => 'Sign Up Now'],
|
||||||
|
'button_url' => ['type' => 'static', 'value' => '#']
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'full']
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function get_about_us_structure()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'image-text',
|
||||||
|
'layoutVariant' => 'image-left',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Our Story'],
|
||||||
|
'text' => ['type' => 'static', 'value' => 'We started with a simple mission: to make web design accessible to everyone. Our journey began in a small garage...'],
|
||||||
|
'image' => ['type' => 'static', 'value' => '']
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'contained']
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'image-text',
|
||||||
|
'layoutVariant' => 'image-right',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Our Vision'],
|
||||||
|
'text' => ['type' => 'static', 'value' => 'To empower businesses of all sizes to have a professional online presence without the technical headache.'],
|
||||||
|
'image' => ['type' => 'static', 'value' => '']
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'contained']
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'content',
|
||||||
|
'props' => [
|
||||||
|
'content' => ['type' => 'static', 'value' => '<h3>Meet the Team</h3><p>Our diverse team of designers and developers...</p>']
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'contained']
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function get_contact_structure()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'content',
|
||||||
|
'props' => [
|
||||||
|
'content' => ['type' => 'static', 'value' => '<h2>Get in Touch</h2><p>We are here to help and answer any question you might have.</p><p><strong>Address:</strong><br>123 Web Street<br>Tech City, TC 90210</p>']
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'contained']
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => self::generate_id(),
|
||||||
|
'type' => 'contact-form',
|
||||||
|
'props' => [
|
||||||
|
'title' => ['type' => 'static', 'value' => 'Send us a Message'],
|
||||||
|
'webhook_url' => ['type' => 'static', 'value' => ''],
|
||||||
|
'redirect_url' => ['type' => 'static', 'value' => '']
|
||||||
|
],
|
||||||
|
'styles' => ['contentWidth' => 'contained']
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,13 @@
|
|||||||
} else {
|
} else {
|
||||||
// Full SPA mode starts at shop
|
// Full SPA mode starts at shop
|
||||||
$page_type = 'shop';
|
$page_type = 'shop';
|
||||||
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
|
|
||||||
|
// If this is the front page, route to /
|
||||||
|
if (is_front_page()) {
|
||||||
|
$data_attrs = 'data-page="shop" data-initial-route="/"';
|
||||||
|
} else {
|
||||||
|
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
1064
woonoow-page-editor-v4-canvas-ui.md
Normal file
1064
woonoow-page-editor-v4-canvas-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,11 @@ add_action('plugins_loaded', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Activation/Deactivation hooks
|
// Activation/Deactivation hooks
|
||||||
register_activation_hook(__FILE__, ['WooNooW\Core\Installer', 'activate']);
|
// Activation/Deactivation hooks
|
||||||
|
register_activation_hook(__FILE__, function() {
|
||||||
|
WooNooW\Core\Installer::activate();
|
||||||
|
WooNooW\Setup\DefaultPages::create_pages();
|
||||||
|
});
|
||||||
register_deactivation_hook(__FILE__, ['WooNooW\Core\Installer', 'deactivate']);
|
register_deactivation_hook(__FILE__, ['WooNooW\Core\Installer', 'deactivate']);
|
||||||
|
|
||||||
// Dev mode filters removed - use wp-config.php if needed:
|
// Dev mode filters removed - use wp-config.php if needed:
|
||||||
|
|||||||
Reference in New Issue
Block a user