diff --git a/admin-spa/package-lock.json b/admin-spa/package-lock.json index f6b5672..7dab128 100644 --- a/admin-spa/package-lock.json +++ b/admin-spa/package-lock.json @@ -57,6 +57,7 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@tailwindcss/typography": "^0.5.19", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^8.46.3", @@ -2898,6 +2899,33 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.5", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", diff --git a/admin-spa/package.json b/admin-spa/package.json index d0c79d1..019d97d 100644 --- a/admin-spa/package.json +++ b/admin-spa/package.json @@ -59,6 +59,7 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@tailwindcss/typography": "^0.5.19", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^8.46.3", diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 7b9941f..ba34127 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -280,6 +280,7 @@ import AppearanceCart from '@/routes/Appearance/Cart'; import AppearanceCheckout from '@/routes/Appearance/Checkout'; import AppearanceThankYou from '@/routes/Appearance/ThankYou'; import AppearanceAccount from '@/routes/Appearance/Account'; +import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor'; import AppearancePages from '@/routes/Appearance/Pages'; import MarketingIndex from '@/routes/Marketing'; import Newsletter from '@/routes/Marketing/Newsletter'; @@ -628,6 +629,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> {/* Marketing */} diff --git a/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx b/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx index 0de80ba..b88c0e0 100644 --- a/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx +++ b/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx @@ -14,24 +14,24 @@ interface BlockRendererProps { isLast: boolean; } -export function BlockRenderer({ - block, - isEditing, - onEdit, - onDelete, - onMoveUp, +export function BlockRenderer({ + block, + isEditing, + onEdit, + onDelete, + onMoveUp, onMoveDown, isFirst, - isLast + isLast }: BlockRendererProps) { - + // Prevent navigation in builder const handleClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; if ( - target.tagName === 'A' || - target.tagName === 'BUTTON' || - target.closest('a') || + target.tagName === 'A' || + target.tagName === 'BUTTON' || + target.closest('a') || target.closest('button') || target.classList.contains('button') || target.classList.contains('button-outline') || @@ -42,7 +42,7 @@ export function BlockRenderer({ e.stopPropagation(); } }; - + const renderBlockContent = () => { switch (block.type) { case 'card': @@ -75,48 +75,48 @@ export function BlockRenderer({ marginBottom: '24px' }, hero: { - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)', color: '#fff', borderRadius: '8px', padding: '32px 40px', marginBottom: '24px' } }; - + // Convert markdown to HTML for visual rendering const htmlContent = parseMarkdownBasics(block.content); - + return (
-
); - + case 'button': { const buttonStyle: React.CSSProperties = block.style === 'solid' ? { - display: 'inline-block', - background: '#7f54b3', - color: '#fff', - padding: '14px 28px', - borderRadius: '6px', - textDecoration: 'none', - fontWeight: 600, - } + display: 'inline-block', + background: 'var(--wn-primary, #7f54b3)', + color: '#fff', + padding: '14px 28px', + borderRadius: '6px', + textDecoration: 'none', + fontWeight: 600, + } : { - display: 'inline-block', - background: 'transparent', - color: '#7f54b3', - padding: '12px 26px', - border: '2px solid #7f54b3', - borderRadius: '6px', - textDecoration: 'none', - fontWeight: 600, - }; + display: 'inline-block', + background: 'transparent', + color: 'var(--wn-secondary, #7f54b3)', + padding: '12px 26px', + border: '2px solid var(--wn-secondary, #7f54b3)', + borderRadius: '6px', + textDecoration: 'none', + fontWeight: 600, + }; const containerStyle: React.CSSProperties = { textAlign: block.align || 'center', @@ -130,7 +130,7 @@ export function BlockRenderer({ buttonStyle.maxWidth = `${block.customMaxWidth}px`; buttonStyle.width = '100%'; } - + return ( ); } - + case 'divider': return
; - + case 'spacer': return
; - + default: return null; } @@ -184,7 +184,7 @@ export function BlockRenderer({
{renderBlockContent()}
- + {/* Hover Controls */}
{!isFirst && ( diff --git a/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx index 709f7ae..1159471 100644 --- a/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx +++ b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx @@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP if (block.type === 'card') { // Convert markdown to HTML for rich text editor const htmlContent = parseMarkdownBasics(block.content); - console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent }); setEditingContent(htmlContent); setEditingCardType(block.cardType); } else if (block.type === 'button') { diff --git a/admin-spa/src/components/MediaUploader.tsx b/admin-spa/src/components/MediaUploader.tsx new file mode 100644 index 0000000..36be8c9 --- /dev/null +++ b/admin-spa/src/components/MediaUploader.tsx @@ -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(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 ( +
+ {children || ( + + )} +
+ ); +} diff --git a/admin-spa/src/components/ui/rich-text-editor.tsx b/admin-spa/src/components/ui/rich-text-editor.tsx index 7da8174..76e82ce 100644 --- a/admin-spa/src/components/ui/rich-text-editor.tsx +++ b/admin-spa/src/components/ui/rich-text-editor.tsx @@ -50,6 +50,8 @@ export function RichTextEditor({ Placeholder.configure({ placeholder, }), + // ButtonExtension MUST come before Link to ensure buttons are parsed first + ButtonExtension, Link.configure({ openOnClick: false, HTMLAttributes: { @@ -65,7 +67,6 @@ export function RichTextEditor({ class: 'max-w-full h-auto rounded', }, }), - ButtonExtension, ], content, onUpdate: ({ editor }) => { diff --git a/admin-spa/src/components/ui/searchable-select.tsx b/admin-spa/src/components/ui/searchable-select.tsx index 934cbaf..330fb23 100644 --- a/admin-spa/src/components/ui/searchable-select.tsx +++ b/admin-spa/src/components/ui/searchable-select.tsx @@ -21,7 +21,7 @@ export interface Option { /** What to render in the button/list. Can be a string or React node. */ label: React.ReactNode; /** Optional text used for filtering. Falls back to string label or value. */ - searchText?: string; + triggerLabel?: React.ReactNode; } interface Props { @@ -55,7 +55,7 @@ export function SearchableSelect({ React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]); return ( - !disabled && setOpen(o)}> + !disabled && setOpen(o)}> diff --git a/admin-spa/src/components/ui/select.tsx b/admin-spa/src/components/ui/select.tsx index c6ed062..6280f65 100644 --- a/admin-spa/src/components/ui/select.tsx +++ b/admin-spa/src/components/ui/select.tsx @@ -89,7 +89,7 @@ const SelectContent = React.forwardRef< ({ return [ { tag: 'a[data-button]', + priority: 100, // Higher priority than Link extension (default 50) getAttrs: (node: HTMLElement) => ({ text: node.getAttribute('data-text') || node.textContent || 'Click Here', href: node.getAttribute('data-href') || node.getAttribute('href') || '#', @@ -47,6 +48,7 @@ export const ButtonExtension = Node.create({ }, { tag: 'a.button', + priority: 100, getAttrs: (node: HTMLElement) => ({ text: node.textContent || 'Click Here', href: node.getAttribute('href') || '#', @@ -55,6 +57,7 @@ export const ButtonExtension = Node.create({ }, { tag: 'a.button-outline', + priority: 100, getAttrs: (node: HTMLElement) => ({ text: node.textContent || 'Click Here', href: node.getAttribute('href') || '#', diff --git a/admin-spa/src/contexts/AppContext.tsx b/admin-spa/src/contexts/AppContext.tsx index c1d18fe..39c14f2 100644 --- a/admin-spa/src/contexts/AppContext.tsx +++ b/admin-spa/src/contexts/AppContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, ReactNode } from 'react'; +import React, { createContext, useContext, ReactNode, useEffect } from 'react'; interface AppContextType { isStandalone: boolean; @@ -7,15 +7,44 @@ interface AppContextType { const AppContext = createContext(undefined); -export function AppProvider({ - children, - isStandalone, - exitFullscreen -}: { - children: ReactNode; - isStandalone: boolean; +export function AppProvider({ + children, + isStandalone, + exitFullscreen +}: { + children: ReactNode; + isStandalone: boolean; exitFullscreen?: () => void; }) { + useEffect(() => { + // Fetch and apply appearance settings (colors) + const loadAppearance = async () => { + try { + const restUrl = (window as any).WNW_CONFIG?.restUrl || ''; + const response = await fetch(`${restUrl}/appearance/settings`); + if (response.ok) { + const result = await response.json(); + // API returns { success: true, data: { general: { colors: {...} } } } + const colors = result.data?.general?.colors; + if (colors) { + const root = document.documentElement; + // Inject all color settings as CSS variables + if (colors.primary) root.style.setProperty('--wn-primary', colors.primary); + if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary); + if (colors.accent) root.style.setProperty('--wn-accent', colors.accent); + if (colors.text) root.style.setProperty('--wn-text', colors.text); + if (colors.background) root.style.setProperty('--wn-background', colors.background); + if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart); + if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd); + } + } + } catch (e) { + console.error('Failed to load appearance settings', e); + } + }; + loadAppearance(); + }, []); + return ( {children} diff --git a/admin-spa/src/lib/html-to-markdown.ts b/admin-spa/src/lib/html-to-markdown.ts index e468138..ff1de82 100644 --- a/admin-spa/src/lib/html-to-markdown.ts +++ b/admin-spa/src/lib/html-to-markdown.ts @@ -68,8 +68,23 @@ export function htmlToMarkdown(html: string): string { }).join('\n') + '\n\n'; }); - // Paragraphs - convert to double newlines - markdown = markdown.replace(/]*>(.*?)<\/p>/gis, '$1\n\n'); + // Paragraphs - preserve text-align by using placeholders + const alignedParagraphs: { [key: string]: string } = {}; + let alignIndex = 0; + markdown = markdown.replace(/]*)>(.*?)<\/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] = `

${content}

`; + alignIndex++; + return placeholder + '\n\n'; + } + // No alignment, convert to plain text + return `${content}\n\n`; + }); // Line breaks markdown = markdown.replace(//gi, '\n'); @@ -80,6 +95,11 @@ export function htmlToMarkdown(html: string): string { // Remove remaining HTML tags markdown = markdown.replace(/<[^>]+>/g, ''); + // Restore aligned paragraphs + Object.entries(alignedParagraphs).forEach(([placeholder, html]) => { + markdown = markdown.replace(placeholder, html); + }); + // Clean up excessive newlines markdown = markdown.replace(/\n{3,}/g, '\n\n'); diff --git a/admin-spa/src/routes/Appearance/General.tsx b/admin-spa/src/routes/Appearance/General.tsx index 956890c..bf9d013 100644 --- a/admin-spa/src/routes/Appearance/General.tsx +++ b/admin-spa/src/routes/Appearance/General.tsx @@ -36,13 +36,15 @@ export default function AppearanceGeneral() { friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' }, elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' }, }; - + const [colors, setColors] = useState({ primary: '#1a1a1a', secondary: '#6b7280', accent: '#3b82f6', text: '#111827', background: '#ffffff', + gradientStart: '#9333ea', // purple-600 defaults + gradientEnd: '#3b82f6', // blue-500 defaults }); useEffect(() => { @@ -51,7 +53,7 @@ export default function AppearanceGeneral() { // Load appearance settings const response = await api.get('/appearance/settings'); const general = response.data?.general; - + if (general) { if (general.spa_mode) setSpaMode(general.spa_mode); if (general.spa_page) setSpaPage(general.spa_page || 0); @@ -70,10 +72,12 @@ export default function AppearanceGeneral() { accent: general.colors.accent || '#3b82f6', text: general.colors.text || '#111827', background: general.colors.background || '#ffffff', + gradientStart: general.colors.gradientStart || '#9333ea', + gradientEnd: general.colors.gradientEnd || '#3b82f6', }); } } - + // Load available pages const pagesResponse = await api.get('/pages/list'); console.log('Pages API response:', pagesResponse); @@ -90,7 +94,7 @@ export default function AppearanceGeneral() { setLoading(false); } }; - + loadSettings(); }, []); @@ -108,7 +112,7 @@ export default function AppearanceGeneral() { }, colors, }); - + toast.success('General settings saved successfully'); } catch (error) { console.error('Save error:', error); @@ -139,7 +143,7 @@ export default function AppearanceGeneral() {

- +
@@ -151,7 +155,7 @@ export default function AppearanceGeneral() {

- +
@@ -175,14 +179,14 @@ export default function AppearanceGeneral() { - This page will render the full SPA to the body element with no theme interference. + This page will render the full SPA to the body element with no theme interference. The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing. - + - @@ -284,7 +288,7 @@ export default function AppearanceGeneral() { )}
- +
@@ -297,7 +301,7 @@ export default function AppearanceGeneral() { Using Google Fonts may not be GDPR compliant - + {typographyMode === 'custom_google' && (
@@ -321,7 +325,7 @@ export default function AppearanceGeneral() {
- +
{Object.entries(colors).map(([key, value]) => ( - +
setColors({ ...colors, [key]: e.target.value })} className="w-20 h-10 cursor-pointer" /> setColors({ ...colors, [key]: e.target.value })} className="flex-1 font-mono" /> diff --git a/admin-spa/src/routes/Appearance/Menus/MenuEditor.tsx b/admin-spa/src/routes/Appearance/Menus/MenuEditor.tsx new file mode 100644 index 0000000..bd0c578 --- /dev/null +++ b/admin-spa/src/routes/Appearance/Menus/MenuEditor.tsx @@ -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) => 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 ( +
+
+
+ +
+ +
setIsEditing(!isEditing)}> +
+ {item.type === 'page' ? : } + {item.type === 'page' ? ( + (() => { + const page = pages.find(p => p.slug === item.value); + if (page?.is_store_page) { + return Store; + } + if (item.value === '/' || page?.is_woonoow_page) { + return WooNooW; + } + return WP; + })() + ) : ( + Custom + )} +
+
+ {item.type === 'page' ? `Page: /${item.value}` : `URL: ${item.value}`} +
+
+ +
+ +
+
+ + {isEditing && ( +
+
+
+ + onUpdate(item.id, { label: e.target.value })} + className="h-8 text-sm" + /> +
+
+ + +
+
+ {item.type === 'custom' && ( +
+ + onUpdate(item.id, { value: e.target.value })} + className="h-8 text-sm font-mono" + /> +
+ )} +
+ )} +
+ ); +} + +export default function MenuEditor() { + const [menus, setMenus] = useState({ primary: [], mobile: [] }); + const [activeTab, setActiveTab] = useState<'primary' | 'mobile'>('primary'); + const [loading, setLoading] = useState(true); + const [pages, setPages] = useState([]); + const [spaPageId, setSpaPageId] = useState(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) => { + 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
; + } + + return ( + +
+ {/* Left Col: Add Items */} + + + Add Items + Add pages or custom links + + +
+ + setNewItemType(v)} className="w-full"> + + Page + Custom URL + + +
+ +
+ + setNewItemLabel(e.target.value)} + /> +
+ +
+ + {newItemType === 'page' ? ( + + Home + WooNooW +
+ ), + triggerLabel: ( +
+ Home + WooNooW +
+ ), + searchText: 'Home' + }, + ...pages.filter(p => p.id !== spaPageId).map(page => { + const Badge = () => { + if (page.is_store_page) { + return Store; + } + if (page.is_woonoow_page) { + return WooNooW; + } + return WP; + }; + + return { + value: page.slug, + label: ( +
+
+ {page.title} + /{page.slug} +
+ +
+ ), + triggerLabel: ( +
+ {page.title} + +
+ ), + searchText: `${page.title} ${page.slug}` + }; + }) + ]} + placeholder="Select a page" + className="w-full" + /> + ) : ( + setNewItemValue(e.target.value)} + /> + )} +
+ + + + + + {/* Right Col: Menu Structure */} +
+ setActiveTab(v)}> + + Primary Menu + Mobile Menu (Optional) + + + + + + Menu Structure + Drag and drop to reorder items + + + + item.id)} + strategy={verticalListSortingStrategy} + > + {menus.primary.length === 0 ? ( +
+ +

No items in menu

+
+ ) : ( + menus.primary.map((item) => ( + + )) + )} +
+
+
+
+
+ + + + + Mobile Menu Structure + + Leave empty to use Primary Menu automatically. + + + + + item.id)} + strategy={verticalListSortingStrategy} + > + {menus.mobile.length === 0 ? ( +
+

Using Primary Menu

+
+ ) : ( + menus.mobile.map((item) => ( + + )) + )} +
+
+
+
+
+
+
+
+ + ); +} diff --git a/admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx b/admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx new file mode 100644 index 0000000..93d2e98 --- /dev/null +++ b/admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx @@ -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; +} + +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> = { + '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(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 ( +
+ {/* Device mode toggle */} +
+ + +
+ + {/* Canvas viewport */} +
+
+ {sections.length === 0 ? ( +
+ +

No sections yet

+

Add your first section to start building

+ + + + + + {SECTION_TYPES.map((type) => ( + onAddSection(type.type)} + > + + {type.label} + + ))} + + +
+ ) : ( +
+ {/* Top Insertion Zone */} + 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. + /> + + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {sections.map((section, index) => { + const Renderer = SECTION_RENDERERS[section.type]; + + return ( + + 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 ? ( + + ) : ( +
+ Unknown section type: {section.type} +
+ )} +
+ + {/* Insertion Zone After Section */} + onAddSection(type, index + 1)} + /> +
+ ); + })} +
+
+
+
+ )} +
+
+
+ ); +} + +// Helper: Insertion Zone Component +function InsertionZone({ index, onAdd }: { index: number; onAdd: (type: string) => void }) { + return ( +
+ {/* Line */} +
+ + {/* Button */} + + + + + + {SECTION_TYPES.map((type) => ( + onAdd(type.type)} + > + + {type.label} + + ))} + + +
+ ); +} diff --git a/admin-spa/src/routes/Appearance/Pages/components/CanvasSection.tsx b/admin-spa/src/routes/Appearance/Pages/components/CanvasSection.tsx new file mode 100644 index 0000000..c367ca8 --- /dev/null +++ b/admin-spa/src/routes/Appearance/Pages/components/CanvasSection.tsx @@ -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 ( +
{ + e.stopPropagation(); + onSelect(); + }} + onMouseEnter={onHover} + onMouseLeave={onLeave} + > + {/* Section content with Styles */} +
+ {/* Background Image & Overlay */} + {section.styles?.backgroundImage && ( + <> +
+
+ + )} + + {/* Content Wrapper */} +
+ {children} +
+
+ + {/* Floating Toolbar (Standard Interaction) */} + {isSelected && ( +
+ {/* Label */} + + {section.type.replace('-', ' ')} + + + {/* Divider */} +
+ + {/* Actions */} + + + +
+
+ + + + + + + + {__('Delete this section?')} + + {__('This action cannot be undone.')} + + + + e.stopPropagation()}>{__('Cancel')} + { + e.stopPropagation(); + onDelete(); + }} + > + {__('Delete')} + + + + +
+ )} + + {/* Active Border Label */} + {isSelected && ( +
+ {section.type} +
+ )} + + {/* Drag Handle (Always visible on hover or select) */} + {(isSelected || isHovered) && ( + + )} +
+ ); +} diff --git a/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx b/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx index 541efec..251f924 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { __ } from '@/lib/i18n'; import { Button } from '@/components/ui/button'; @@ -35,6 +35,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod const [pageType, setPageType] = useState<'page' | 'template'>('page'); const [title, setTitle] = useState(''); const [slug, setSlug] = useState(''); + const [selectedTemplateId, setSelectedTemplateId] = useState('blank'); // Prevent double submission const isSubmittingRef = useRef(false); @@ -42,9 +43,18 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod // Get site URL from WordPress config const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin; + // Fetch templates + const { data: templates = [] } = useQuery({ + queryKey: ['templates-presets'], + queryFn: async () => { + const res = await api.get('/templates/presets'); + return res as { id: string; label: string; description: string; icon: string }[]; + } + }); + // Create page mutation const createMutation = useMutation({ - mutationFn: async (data: { title: string; slug: string }) => { + mutationFn: async (data: { title: string; slug: string; templateId?: string }) => { // Guard against double submission if (isSubmittingRef.current) { throw new Error('Request already in progress'); @@ -53,7 +63,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod try { // api.post returns JSON directly (not wrapped in { data: ... }) - const response = await api.post('/pages', { title: data.title, slug: data.slug }); + const response = await api.post('/pages', { + title: data.title, + slug: data.slug, + templateId: data.templateId + }); return response; // Return response directly, not response.data } finally { // Reset after a delay to prevent race conditions @@ -74,6 +88,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod onOpenChange(false); setTitle(''); setSlug(''); + setSelectedTemplateId('blank'); } }, onError: (error: any) => { @@ -105,7 +120,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod return; } if (pageType === 'page' && title && slug) { - createMutation.mutate({ title, slug }); + createMutation.mutate({ title, slug, templateId: selectedTemplateId }); } }; @@ -115,6 +130,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod setTitle(''); setSlug(''); setPageType('page'); + setSelectedTemplateId('blank'); isSubmittingRef.current = false; } }, [open]); @@ -123,7 +139,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod return ( - + {__('Create New Page')} @@ -133,8 +149,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
{/* Page Type Selection */} - setPageType(v as 'page' | 'template')}> -
+ setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4"> +
setPageType('page')} + >
-
+