import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useEditor } from '@craftjs/core'; import { usePages } from '../../state/PageContext'; import { useSiteDesign } from '../../state/SiteDesignContext'; import { allTemplates, TemplateDefinition, TemplateComponent, TemplateCategory, } from '../../templates'; import { componentResolver } from '../../components/resolver'; import { clickableProps } from '../../utils/a11y'; import { Modal } from '../../ui/Modal'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface TemplateModalProps { open: boolean; onClose: () => void; } type FilterTab = 'all' | TemplateCategory; const TABS: { label: string; value: FilterTab }[] = [ { label: 'All', value: 'all' }, { label: 'Business', value: 'business' }, { label: 'Creative', value: 'creative' }, { label: 'Personal', value: 'personal' }, { label: 'Community', value: 'community' }, ]; const CATEGORY_COLORS: Record = { business: '#3b82f6', creative: '#a855f7', personal: '#f59e0b', community: '#10b981', }; // --------------------------------------------------------------------------- // Modal Component // --------------------------------------------------------------------------- export const TemplateModal: React.FC = ({ open, onClose }) => { const { actions, query } = useEditor(); const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages(); const { updateDesign } = useSiteDesign(); const [activeTab, setActiveTab] = useState('all'); const [confirmTemplate, setConfirmTemplate] = useState(null); const [applyDesign, setApplyDesign] = useState(true); const [loading, setLoading] = useState(false); // Guards state updates after the component has unmounted mid-sequence. const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Escape / backdrop-click close the confirmation dialog first if it's open, // otherwise close the modal. Passed to below, which handles the // Escape listener and the backdrop-click detection itself; the header's own // close button (X) below still uses the raw `onClose` prop directly, so it // always fully closes the modal even mid-confirmation (unchanged behavior). const handleModalClose = useCallback(() => { if (confirmTemplate) setConfirmTemplate(null); else onClose(); }, [confirmTemplate, onClose]); // Filter templates by category const filtered = useMemo(() => { if (activeTab === 'all') return allTemplates; return allTemplates.filter((t) => t.category === activeTab); }, [activeTab]); // Resolve a TemplateComponent type name to its React component const resolverMap = componentResolver as Record>; /** * Add all components from a template definition onto the current (empty) canvas ROOT. * Uses Craft.js parseReactElement + addNodeTree which correctly builds valid node structures. */ const addTemplateComponents = useCallback( (components: TemplateComponent[]) => { for (const comp of components) { const Component = resolverMap[comp.type]; if (!Component) { console.warn(`Template references unknown component type: ${comp.type}`); continue; } const element = React.createElement(Component, comp.props); const tree = query.parseReactElement(element).toNodeTree(); actions.addNodeTree(tree, 'ROOT'); } }, [query, actions, resolverMap], ); /** * Clear the current canvas by deleting all children of ROOT. */ const clearCanvas = useCallback(() => { try { const rootNode = query.node('ROOT').get(); const childIds = [...(rootNode.data.nodes || [])]; childIds.forEach((id) => { try { actions.delete(id); } catch { // Node may already be removed } }); } catch { // ROOT doesn't exist yet or is empty -- that's fine } }, [query, actions]); /** Promise-based `setTimeout` — lets the staged apply steps below be awaited * in sequence instead of chained via nested callbacks. */ const wait = useCallback((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), []); /** * Switch to a zone (header/footer), clear it, and add the template's * components, giving Craft.js a tick to settle between each step. */ const applyZone = useCallback( async (switchFn: () => void, components: TemplateComponent[]) => { switchFn(); await wait(30); try { clearCanvas(); await wait(20); try { addTemplateComponents(components); } catch (e) { console.warn('Failed to add zone components:', e); } } catch (e) { console.warn('Failed to clear zone:', e); } await wait(30); }, [wait, clearCanvas, addTemplateComponents], ); /** * After all pages are loaded, apply header and footer template content. * Switches to header/footer editing mode, clears, adds components, then returns to firstPageId. */ const applyHeaderFooter = useCallback( async (tpl: TemplateDefinition, firstPageId: string) => { const hasHeader = tpl.header?.components?.length > 0; const hasFooter = tpl.footer?.components?.length > 0; if (hasHeader) await applyZone(editHeader, tpl.header.components); if (hasFooter) await applyZone(editFooter, tpl.footer.components); // Switch back to the first page switchPage(firstPageId); await wait(30); }, [applyZone, switchPage, editHeader, editFooter, wait], ); // Load the selected template. Awaits the full staged sequence (clear, add // components, add pages, apply header/footer) before closing the modal, so // "Loading…" stays visible for the duration and onClose() never fires // while the sequence is still mutating a canvas the dialog just tore down. const handleLoad = useCallback(async () => { if (!confirmTemplate) return; setLoading(true); const tpl = confirmTemplate; setConfirmTemplate(null); try { // 1. Optionally apply design tokens if (applyDesign && tpl.design) { updateDesign(tpl.design); } // 2. Remove all pages except the first const currentPages = [...pages]; const keepId = currentPages[0]?.id; for (let i = currentPages.length - 1; i >= 1; i--) { deletePage(currentPages[i].id); } // 3. Clear the current canvas and add the first template page's components clearCanvas(); // Short delay to let the clear settle before adding new nodes await wait(20); const firstPage = tpl.pages[0]; addTemplateComponents(firstPage.content.components); // 4. Add remaining pages (if multi-page template) if (tpl.pages.length > 1) { await wait(30); for (let pageIndex = 1; pageIndex < tpl.pages.length; pageIndex++) { const pageDef = tpl.pages[pageIndex]; addPage(pageDef.name, pageDef.slug); // After addPage, the new page is active with an empty canvas. // Give a tick for Craft.js to settle, then add components. await wait(30); try { addTemplateComponents(pageDef.content.components); } catch (e) { console.warn('Failed to add components for page', pageDef.name, e); } await wait(30); } // Switch back to the first page before applying header/footer if (keepId) switchPage(keepId); await wait(30); } // 5. Apply header and footer template content if (keepId) { await applyHeaderFooter(tpl, keepId); } } catch (e) { console.error('Failed to load template:', e); } finally { if (mountedRef.current) setLoading(false); onClose(); } }, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]); return (
{/* Header */}

Templates

Choose a template to get started quickly

{/* Filter Tabs */}
{TABS.map((tab) => ( ))}
{/* Template Grid */}
{filtered.map((tpl) => ( setConfirmTemplate(tpl)} /> ))} {filtered.length === 0 && (
No templates in this category.
)}
{/* Confirmation Dialog */} {confirmTemplate && (
setConfirmTemplate(null)}>
e.stopPropagation()}>

Load "{confirmTemplate.name}"?

This will replace your current content with the template pages and components.

)}
); }; // --------------------------------------------------------------------------- // TemplateCard sub-component // --------------------------------------------------------------------------- const TemplateCard: React.FC<{ template: TemplateDefinition; onSelect: () => void; }> = ({ template, onSelect }) => { const [hovered, setHovered] = useState(false); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ borderRadius: 8, border: `1px solid ${hovered ? 'var(--color-accent)' : 'var(--color-border)'}`, backgroundColor: hovered ? 'var(--color-bg-elevated)' : 'var(--color-bg-surface)', cursor: 'pointer', overflow: 'hidden', transition: 'all 0.2s ease', transform: hovered ? 'translateY(-2px)' : 'none', boxShadow: hovered ? '0 4px 12px rgba(0,0,0,0.3)' : 'none', }} > {/* Thumbnail */}
{template.name}
{/* Info */}
{template.name} {template.category}

{template.description}

{template.isMultiPage ? `${template.pages.length} pages` : 'Single page'}
); }; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const modalStyle: React.CSSProperties = { width: '90vw', maxWidth: 900, maxHeight: '85vh', backgroundColor: 'var(--color-bg-surface)', borderRadius: 12, border: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.5)', }; const modalHeaderStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', borderBottom: '1px solid var(--color-border)', flexShrink: 0, }; const closeButtonStyle: React.CSSProperties = { width: 32, height: 32, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: '#71717a', background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', }; const tabBarStyle: React.CSSProperties = { display: 'flex', gap: 4, padding: '12px 20px', borderBottom: '1px solid var(--color-border)', flexShrink: 0, overflowX: 'auto', }; const tabStyle: React.CSSProperties = { padding: '6px 16px', fontSize: 12, fontWeight: 500, color: '#71717a', background: 'transparent', border: '1px solid transparent', borderRadius: 6, cursor: 'pointer', whiteSpace: 'nowrap', transition: 'all 0.15s ease', }; const tabActiveStyle: React.CSSProperties = { color: '#e4e4e7', background: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)', }; const gridContainerStyle: React.CSSProperties = { flex: 1, overflowY: 'auto', padding: '16px 20px 20px', }; const gridStyle: React.CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 16, }; const confirmOverlayStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 12, zIndex: 1, }; const confirmDialogStyle: React.CSSProperties = { width: '90%', maxWidth: 420, padding: '24px', backgroundColor: 'var(--color-bg-surface)', borderRadius: 10, border: '1px solid var(--color-border)', boxShadow: '0 8px 32px rgba(0,0,0,0.4)', };