import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'; import { useEditor } from '@craftjs/core'; import { PageData } from '../types'; import { SerializedTreeNode } from '../types/sitesmith'; import { useSiteDesign, SiteDesign } from './SiteDesignContext'; interface PageContextValue { pages: PageData[]; headerPage: PageData; footerPage: PageData; activePageId: string; isEditingHeader: boolean; isEditingFooter: boolean; switchPage: (pageId: string) => void; editHeader: () => void; editFooter: () => void; addPage: (name: string, slug: string) => void; deletePage: (pageId: string) => void; renamePage: (pageId: string, name: string, slug: string) => void; setHeaderCraftState: (craftState: string) => void; setFooterCraftState: (craftState: string) => void; setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void; /** AI helpers — replace entire site or page with a new tree */ replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void; replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void; setHeader: (tree: SerializedTreeNode) => void; setFooter: (tree: SerializedTreeNode) => void; siteDesign: SiteDesign; } const HEADER_ID = '__header__'; const FOOTER_ID = '__footer__'; const EMPTY_CANVAS = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; const EMPTY_HEADER = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#ffffff","padding":"12px 24px","display":"flex","alignItems":"center"},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; const EMPTY_FOOTER = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; const PageContext = createContext({ pages: [], headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' }, footerPage: { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null, headCode: '' }, activePageId: 'home', isEditingHeader: false, isEditingFooter: false, switchPage: () => {}, editHeader: () => {}, editFooter: () => {}, addPage: () => {}, deletePage: () => {}, renamePage: () => {}, setHeaderCraftState: () => {}, setFooterCraftState: () => {}, setPagesCraftState: () => {}, replaceAllPages: () => {}, replaceCurrentPage: () => {}, setHeader: () => {}, setFooter: () => {}, siteDesign: {} as SiteDesign, }); export const usePages = () => useContext(PageContext); function slugify(name: string): string { return name .toLowerCase() .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); } const DEFAULT_PAGE: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: null, headCode: '', }; const DEFAULT_HEADER: PageData = { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '', }; const DEFAULT_FOOTER: PageData = { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null, headCode: '', }; export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { query, actions } = useEditor(); const { design } = useSiteDesign(); const [pages, setPages] = useState([DEFAULT_PAGE]); const [headerPage, setHeaderPage] = useState(DEFAULT_HEADER); const [footerPage, setFooterPage] = useState(DEFAULT_FOOTER); const [activePageId, setActivePageId] = useState('home'); const activePageIdRef = useRef(activePageId); activePageIdRef.current = activePageId; const isEditingHeader = activePageId === HEADER_ID; const isEditingFooter = activePageId === FOOTER_ID; /** Save whatever is on the current Frame back to the right state slot */ const saveCurrentState = useCallback(() => { const currentState = query.serialize(); const currentId = activePageIdRef.current; if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, craftState: currentState })); } else if (currentId === FOOTER_ID) { setFooterPage((prev) => ({ ...prev, craftState: currentState })); } else { setPages((prev) => prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)), ); } }, [query]); /** Load a craft state into the Frame */ const loadState = useCallback( (craftState: string | null, fallback: string) => { setTimeout(() => { try { actions.deserialize(craftState || fallback); } catch (e) { console.error('Failed to deserialize state:', e); try { actions.deserialize(fallback); } catch (_e2) { // give up } } }, 0); }, [actions], ); const switchPage = useCallback( (pageId: string) => { if (pageId === activePageIdRef.current) return; // Serialize the current Craft.js state synchronously BEFORE switching const currentState = query.serialize(); const currentId = activePageIdRef.current; // Persist the serialized state to the correct page slot if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, craftState: currentState })); } else if (currentId === FOOTER_ID) { setFooterPage((prev) => ({ ...prev, craftState: currentState })); } else { setPages((prev) => prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)), ); } // Load target page state. // For header/footer we need the latest saved state. Since setState above is async, // read from the ref-like state getter. For header/footer, we read the current // state value and fall back to what we just saved if the target is the same slot. if (pageId === HEADER_ID) { // Use functional state read to get the latest value setHeaderPage((prev) => { loadState(prev.craftState, EMPTY_HEADER); return prev; }); } else if (pageId === FOOTER_ID) { setFooterPage((prev) => { loadState(prev.craftState, EMPTY_FOOTER); return prev; }); } else { setPages((prev) => { const target = prev.find((p) => p.id === pageId); loadState(target?.craftState || null, EMPTY_CANVAS); return prev; }); } setActivePageId(pageId); activePageIdRef.current = pageId; }, [query, loadState], ); const editHeader = useCallback(() => { switchPage(HEADER_ID); }, [switchPage]); const editFooter = useCallback(() => { switchPage(FOOTER_ID); }, [switchPage]); const addPage = useCallback( (name: string, slug: string) => { const finalSlug = slug || slugify(name); const id = `page_${Date.now()}`; // Save current page first saveCurrentState(); setPages((prev) => [ ...prev, { id, name, slug: finalSlug, craftState: null, headCode: '', }, ]); // Switch to the new page with empty canvas loadState(null, EMPTY_CANVAS); setActivePageId(id); activePageIdRef.current = id; }, [saveCurrentState, loadState], ); const deletePage = useCallback( (pageId: string) => { // Can't delete header/footer if (pageId === HEADER_ID || pageId === FOOTER_ID) return; setPages((prev) => { if (prev.length <= 1) return prev; const filtered = prev.filter((p) => p.id !== pageId); // If deleting the active page, switch to the first remaining if (pageId === activePageIdRef.current) { const nextPage = filtered[0]; setActivePageId(nextPage.id); activePageIdRef.current = nextPage.id; loadState(nextPage.craftState, EMPTY_CANVAS); } return filtered; }); }, [loadState], ); const renamePage = useCallback((pageId: string, name: string, slug: string) => { setPages((prev) => prev.map((p) => p.id === pageId ? { ...p, name, slug: slug || slugify(name) } : p, ), ); }, []); /** Allow external code (e.g., load from API) to set the header craft state */ const setHeaderCraftState = useCallback((craftState: string) => { setHeaderPage((prev) => ({ ...prev, craftState })); }, []); /** Allow external code (e.g., load from API) to set the footer craft state */ const setFooterCraftState = useCallback((craftState: string) => { setFooterPage((prev) => ({ ...prev, craftState })); }, []); /** Allow external code (e.g., load from API) to restore pages with craft states */ const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => { setPages(pagesData.map((p) => ({ id: p.id, name: p.name, slug: p.slug, craftState: p.craftState, headCode: '', }))); }, []); /** Flatten a SerializedTreeNode into a Craft.js SerializedNodes JSON string */ const treeToState = useCallback((tree: SerializedTreeNode): string => { let counter = 0; const nodes: Record = {}; const walk = (node: SerializedTreeNode, parent: string | null): string => { const id = (node.props.node_id as string | undefined) || `ai-auto-${counter++}`; const childIds: string[] = []; nodes[id] = { type: node.type, isCanvas: true, props: node.props, displayName: node.type.resolvedName, custom: {}, hidden: false, parent, nodes: childIds, linkedNodes: {}, }; for (const child of node.nodes ?? []) { childIds.push(walk(child, id)); } return id; }; const rootId = walk(tree, null); // Craft.js deserialize requires the root node keyed as 'ROOT' if (rootId !== 'ROOT') { nodes['ROOT'] = nodes[rootId]; (nodes['ROOT'] as any).parent = null; delete nodes[rootId]; // Fix up parent references from ROOT's children for (const childId of (nodes['ROOT'] as any).nodes) { if (nodes[childId]) (nodes[childId] as any).parent = 'ROOT'; } } return JSON.stringify(nodes); }, []); /** * AI helper: replace all pages with newly generated trees. * Stores each page's serialized state without touching the live canvas * (the canvas still shows the currently active page — call switchPage() if needed). */ const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => { if (newPages.length === 0) return; const built = newPages.map((p, i) => ({ id: i === 0 ? 'home' : `page_${Date.now()}_${i}`, name: p.name, slug: slugify(p.name), craftState: treeToState(p.tree), headCode: '', })); setPages(built); // Load the first page into the live canvas const firstState = built[0].craftState; setActivePageId(built[0].id); activePageIdRef.current = built[0].id; loadState(firstState, EMPTY_CANVAS); }, [treeToState, loadState]); /** * AI helper: replace the current page's tree. * Deserializes the new tree into the live Craft.js canvas and persists it. */ const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => { const craftState = treeToState(page.tree); const currentId = activePageIdRef.current; if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, name: page.name, craftState })); } else if (currentId === FOOTER_ID) { setFooterPage((prev) => ({ ...prev, name: page.name, craftState })); } else { setPages((prev) => prev.map((p) => (p.id === currentId ? { ...p, name: page.name, craftState } : p)), ); } loadState(craftState, EMPTY_CANVAS); }, [treeToState, loadState]); /** * AI helper: replace the shared header tree. * Updates stored state; does NOT switch the canvas to header view. */ const setHeader = useCallback((tree: SerializedTreeNode) => { const craftState = treeToState(tree); setHeaderPage((prev) => ({ ...prev, craftState })); // If the canvas is currently showing the header, refresh it live if (activePageIdRef.current === HEADER_ID) { loadState(craftState, EMPTY_HEADER); } }, [treeToState, loadState]); /** * AI helper: replace the shared footer tree. * Updates stored state; does NOT switch the canvas to footer view. */ const setFooter = useCallback((tree: SerializedTreeNode) => { const craftState = treeToState(tree); setFooterPage((prev) => ({ ...prev, craftState })); // If the canvas is currently showing the footer, refresh it live if (activePageIdRef.current === FOOTER_ID) { loadState(craftState, EMPTY_FOOTER); } }, [treeToState, loadState]); return ( {children} ); };