import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'; import { useEditor } from '@craftjs/core'; import { PageData, PageSeo } from '../types'; import { SerializedTreeNode } from '../types/sitesmith'; import { useSiteDesign, SiteDesign } from './SiteDesignContext'; import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree'; import { repairOrphanNodes } from '../utils/orphan-repair'; 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; /** * Duplicates `pageId`, inserting the copy immediately after the source in * `pages` and switching the canvas to the new copy. If `pageId` is the * active page, its current on-canvas state is saved first so the copy * (and the original) both reflect what's actually on screen. The copy * gets its own unique slug (never `'index'` -- it's never at index 0) and * a name of `" copy"`; its `seo` is copied from the source. */ duplicatePage: (pageId: string) => void; /** * Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with * the adjacent page; no-op at either end). Does NOT touch the live * canvas -- only list order changes. Re-applies the landing-page * invariant afterward (see `applyLandingInvariant`) since a reorder can * move a different page into/out of index 0. */ movePage: (pageId: string, direction: 'up' | 'down') => void; /** * Moves `pageId` to index 0 (making it the new landing page) and * re-applies the landing-page invariant. Does NOT touch the live canvas. */ setLandingPage: (pageId: string) => void; /** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */ updatePageSeo: (pageId: string, seo: Partial) => void; setHeaderCraftState: (craftState: string) => void; setFooterCraftState: (craftState: string) => void; setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => void; /** * Bookkeeping-only: point `activePageId` at an already-loaded page without * re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's * `load()` -- has already put the right state on the canvas itself). Used * to fix I-1: `activePageId` defaults to the hardcoded `'home'` and * `load()` never updated it, so after the original Home page was deleted * (its replacement gets a fresh `page_` id) and the app reloaded, * `activePageId` pointed at nothing in `pages`, and a subsequent edit+save * only reached the legacy top-level fields. */ setActivePageIdDirect: (pageId: string) => 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__'; // M-3: `page_${Date.now()}` alone collides when two pages are minted inside // the same millisecond (addPage called twice in quick succession, or two AI // replaceAllPages entries) -- then rename/delete/save operate on both pages // at once since they share an id. A module-scoped monotonic counter, // combined with the timestamp, guarantees uniqueness regardless of how many // ids are minted within the same millisecond. This is state/id-minting code // (not toHtml/export), so Date.now() here is fine -- see task-minors-brief.md. let pageIdCounter = 0; /** Mints a unique page id: timestamp (base36) + a monotonic per-process counter (base36). */ export function nextPageId(): string { return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36); } export 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":{}}}'; /** * Flatten a `SerializedTreeNode` (as produced by the AI, a template, etc.) * into a Craft.js `SerializedNodes` JSON string ready for * `actions.deserialize()`. * * Untrusted input is validated the same way `apply-ai-response.ts`'s * `buildNodeTree` validates AI `patch`/section-replace trees: any node whose * `type.resolvedName` isn't a registered component is dropped (subtree and * all) via `sanitizeAiTree`, and a bad/colliding/`'ROOT'` id is regenerated * — never left as-is. This matters here specifically because `replace` * scope `site`/`page` responses reach this function via `actions.deserialize` * with no further validation downstream, unlike the `buildNodeTree` path * which also gets Craft.js's own `parseFreshNode` as a second line of * defense. If the ROOT node itself is invalid, sanitizeAiTree returns null * and we fall back to an empty canvas rather than handing deserialize() * something that could throw. * * Exported standalone (not a hook) so it's directly unit-testable without * mounting a Craft.js ``. */ export function treeToCraftState(tree: SerializedTreeNode): string { const sanitized = sanitizeAiTree(tree, new Set()); if (!sanitized) return EMPTY_CANVAS; const { rootNodeId, nodes: flatNodes } = flattenTreeForCraft(sanitized); const nodes: Record = flatNodes; // Craft.js deserialize requires the root node keyed as 'ROOT'. if (rootNodeId !== 'ROOT') { nodes['ROOT'] = { ...nodes[rootNodeId], parent: null, isCanvas: true }; delete nodes[rootNodeId]; for (const childId of nodes['ROOT'].nodes) { if (nodes[childId]) nodes[childId].parent = 'ROOT'; } // I-2: linkedNodes children (e.g. ColumnLayout's col-0/col-1, or a // Section/BackgroundSection/FormContainer's SHELL_INNER wrapper) need // the same reparenting as nodes[] children above. Without this, a // ColumnLayout/SHELL_INNER-rooted AI `replace` leaves those children's // `parent` pointing at the OLD root id, which is then `delete`d -- // producing a dangling parent reference that breaks select/move/delete // of those nodes in the Craft.js editor. for (const linkedId of Object.values(nodes['ROOT'].linkedNodes)) { if (nodes[linkedId]) nodes[linkedId].parent = 'ROOT'; } } return JSON.stringify(nodes); } // Default header seed: a ROOT header Container holding a Navbar with default // links. New sites previously opened with an EMPTY header (just a bare // Container), so there was no menu to edit and the empty zone rendered as a // stray band above the page. Seeding a real Navbar gives every new site an // editable menu-with-links out of the box (and removes the empty-header gap). // // Item 15: the nav used to link Home/About/Services/Contact, but a brand // new site only has a "Home" page -- About/Services/Contact were dead links // on first click. Rather than seeding three empty placeholder pages nobody // asked for, the simpler default is a nav with just the one real page (Home) // plus a CTA button, which is inert (`href: '#'`) rather than pointing at a // page that doesn't exist. Users add pages/links as their site grows. // Node shape matches treeToCraftState() / Craft's actions.deserialize(). export const DEFAULT_HEADER_STATE = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: { width: '100%' }, tag: 'header' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['header-navbar'], linkedNodes: {}, }, 'header-navbar': { type: { resolvedName: 'Navbar' }, isCanvas: false, props: { logoType: 'text', logoText: 'MySite', logoImage: '', logoWidth: '120px', logoUrl: '/', logoFontFamily: 'Inter, sans-serif', logoFontSize: '20px', links: [ { text: 'Home', href: '/' }, { text: 'Get Started', href: '#', isCta: true }, ], backgroundColor: '#ffffff', textColor: '#3f3f46', hoverColor: '#3b82f6', ctaColor: '#3b82f6', ctaTextColor: '#ffffff', padding: '16px 24px', navAlignment: 'space-between', isSticky: false, showMobileMenu: false, style: { borderBottom: '1px solid #e4e4e7' }, }, displayName: 'Navbar', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {}, }, }); const PageContext = createContext({ pages: [], headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null }, footerPage: { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null }, activePageId: 'home', isEditingHeader: false, isEditingFooter: false, switchPage: () => {}, editHeader: () => {}, editFooter: () => {}, addPage: () => {}, deletePage: () => {}, renamePage: () => {}, duplicatePage: () => {}, movePage: () => {}, setLandingPage: () => {}, updatePageSeo: () => {}, setHeaderCraftState: () => {}, setFooterCraftState: () => {}, setPagesCraftState: () => {}, setActivePageIdDirect: () => {}, replaceAllPages: () => {}, replaceCurrentPage: () => {}, setHeader: () => {}, setFooter: () => {}, siteDesign: {} as SiteDesign, }); export const usePages = () => useContext(PageContext); function slugify(name: string): string { const slug = name .toLowerCase() .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); // M-4: a punctuation-only name (e.g. "!!!") strips down to '' -- without a // fallback, buildSavePayload would write filename = '' + '.html' for that // page. uniqueSlug's existing dedupe logic then applies on top of this // fallback the same way it does for any other base ('page', 'page-2', ...). return slug || 'page'; } /** * Append `-2`, `-3`, … to `base` until it no longer collides with * `existingSlugs`. Two pages that slugify to the same string (e.g. both * named "About") must not both publish to `about.html` — the second write * would silently overwrite the first on publish. */ export function uniqueSlug(base: string, existingSlugs: string[]): string { if (!existingSlugs.includes(base)) return base; let i = 2; while (existingSlugs.includes(`${base}-${i}`)) i++; return `${base}-${i}`; } /** * Re-establishes the landing-page invariant on a REORDERED pages array: the * page now at index 0 is the landing page and its slug is locked to * `'index'` (regardless of whatever slug it held before it was moved there); * every other page keeps its slug UNLESS it's the page that previously held * `'index'` and has now been demoted to index > 0 -- that page needs a real, * unique slug of its own (derived from its name) since a page can no longer * publish to `index.html` from anywhere but index 0. * * Pure function of the array -- used by both `movePage` (swap two adjacent * pages) and `setLandingPage` (move an arbitrary page to index 0) as the * shared "fix the invariant up after reordering" step, and directly * unit-testable without mounting `PageProvider`. * * Normally at most one page enters with slug `'index'` (true for any array * that already satisfied the invariant before the reorder that produced this * input) -- exactly the case both callers hand it. Defensively, though, a * STRAY second page with slug `'index'` at index > 0 (e.g. from legacy * loaded data that predates this invariant) is also demoted rather than left * as a duplicate -- see the running `usedSlugs` accumulation below. */ export function applyLandingInvariant(pages: PageData[]): PageData[] { if (pages.length === 0) return pages; // Slugs that must not be collided into: 'index' (reserved for whoever // ends up at index 0) plus every non-landing page's existing slug except // any demoted page's (it currently holds 'index' and is about to be given // a new one). Computed upfront, over the WHOLE array, so a demoted page's // new slug is checked against every other page regardless of array order // -- checking only "slugs seen so far" while walking the array would miss // a collision against a page that appears LATER in the list than the // demoted one. Mutated (pushed to) as pages are demoted below so that two // demoted pages in the same pass can't collide with EACH OTHER either. const usedSlugs: string[] = ['index']; for (let i = 1; i < pages.length; i++) { if (pages[i].slug !== 'index') usedSlugs.push(pages[i].slug); } return pages.map((page, i) => { if (i === 0) { return page.slug === 'index' ? page : { ...page, slug: 'index' }; } if (page.slug === 'index') { // Demoted landing page (or a stray extra 'index' page -- see doc // comment above) -- give it a real, unique slug of its own. const newSlug = uniqueSlug(slugify(page.name), usedSlugs); usedSlugs.push(newSlug); return { ...page, slug: newSlug }; } return page; }); } const DEFAULT_PAGE: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: null, }; const DEFAULT_HEADER: PageData = { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: DEFAULT_HEADER_STATE, }; const DEFAULT_FOOTER: PageData = { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null, }; 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; // Mirror the latest state in refs so event handlers (switchPage, // deletePage) can synchronously read "what's current" without smuggling a // side effect into a setState updater function to peek at `prev` — updater // functions must stay pure since React (StrictMode) may invoke them twice. const pagesRef = useRef(pages); pagesRef.current = pages; const headerPageRef = useRef(headerPage); headerPageRef.current = headerPage; const footerPageRef = useRef(footerPage); footerPageRef.current = footerPage; 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. * * Every state goes through `repairOrphanNodes` first: a node present in * the serialized state but not reachable from ROOT via `nodes`/ * `linkedNodes` is never instantiated by Craft.js's `` at all -- * it doesn't render, so it isn't merely unselectable, it's invisible and * otherwise unrecoverable. Reattaching it to the end of ROOT makes it an * ordinary child the user can see, select and delete. Cheap (single JSON * round-trip) and a no-op -- returning the identical string -- for the * overwhelmingly common healthy case. * * Note this orphan-node repair is a different mechanism from the * originally-reported symptom (a *visible* element on the canvas that * can't be selected or deleted) -- that report is still unreproduced; * see `orphan-repair.ts` for detail. */ const loadState = useCallback( (craftState: string | null, fallback: string) => { setTimeout(() => { const source = craftState || fallback; const { state, repaired } = repairOrphanNodes(source); if (repaired.length > 0) { // I5: console-buffer.ts only patches console.error, and this // reattach signal is the single most diagnostic clue for the // still-unreproduced "elements drop off the canvas" report -- it // must reach the in-builder issue reporter's console buffer. console.error( `[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`, repaired.join(', '), ); } try { actions.deserialize(state); } catch (e) { console.error('Failed to deserialize state:', e); try { // NOT run through repairOrphanNodes: `fallback` must always be // one of the module's own known-safe constants (EMPTY_CANVAS / // EMPTY_HEADER / EMPTY_FOOTER -- true at all current call sites), // never untrusted/stored data, since this is the last line of // defense before giving up silently below. 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. `pageId` can never equal `currentId` here // (guarded by the early return above), so the target's stored state // was untouched by the setState calls just above — the refs (kept in // sync with state on every render) are safe to read synchronously // without waiting for a re-render, and without running the load as a // side effect inside a setState updater. if (pageId === HEADER_ID) { loadState(headerPageRef.current.craftState, EMPTY_HEADER); } else if (pageId === FOOTER_ID) { loadState(footerPageRef.current.craftState, EMPTY_FOOTER); } else { const target = pagesRef.current.find((p) => p.id === pageId); loadState(target?.craftState || null, EMPTY_CANVAS); } 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 requestedSlug = slug || slugify(name); const id = nextPageId(); // Save current page first saveCurrentState(); setPages((prev) => [ ...prev, { id, name, slug: uniqueSlug(requestedSlug, prev.map((p) => p.slug)), craftState: null, }, ]); // 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; const prev = pagesRef.current; if (prev.length <= 1) return; const filtered = prev.filter((p) => p.id !== pageId); setPages(filtered); // If deleting the active page, switch to the first remaining. This // runs after the state update is computed (not inside the setPages // updater) so the side effects (deserialize, ref/state mutation) fire // exactly once regardless of how many times React invokes updaters. if (pageId === activePageIdRef.current) { const nextPage = filtered[0]; setActivePageId(nextPage.id); activePageIdRef.current = nextPage.id; loadState(nextPage.craftState, EMPTY_CANVAS); } }, [loadState], ); /** * Duplicates `pageId`: inserts a copy immediately after the source in * `pages` and switches the live canvas to it. See the doc comment on * `PageContextValue.duplicatePage`. */ const duplicatePage = useCallback( (pageId: string) => { // Always persist whatever is on the live canvas back into its page // slot BEFORE any teardown below (same as addPage/switchPage/deletePage // do unconditionally). Without this, duplicating a page OTHER than the // active one would tear down and switch the canvas via loadState() // further down without ever serializing the outgoing active page's // live edits into its slot -- silently discarding them. saveCurrentState(); const isActive = pageId === activePageIdRef.current; const source = pagesRef.current.find((p) => p.id === pageId); if (!source) return; // If the source IS the active page, saveCurrentState() above just // wrote the live canvas into `source.craftState`'s slot -- but // `pagesRef.current` (captured above) may still be the pre-update // snapshot depending on render timing, so ask Craft.js directly for // the same value rather than re-reading the ref. If the source is a // NON-active page, its stored craftState is untouched by saving the // (different) active page above, so use it as-is. const sourceCraftState = isActive ? query.serialize() : source.craftState; const otherSlugs = pagesRef.current.map((p) => p.slug); const copyId = nextPageId(); const copyName = `${source.name} copy`; // The copy is always inserted AFTER the source (index >= 1), so it // never needs the reserved 'index' slug -- a normal unique slug always // applies here regardless of whether the source itself is the landing // page. const copySlug = uniqueSlug(slugify(copyName), otherSlugs); const copy: PageData = { id: copyId, name: copyName, slug: copySlug, craftState: sourceCraftState, seo: source.seo ? { ...source.seo } : undefined, }; setPages((prev) => { const idx = prev.findIndex((p) => p.id === pageId); if (idx === -1) return prev; const next = [...prev]; next.splice(idx + 1, 0, copy); return next; }); // Switch the canvas to the new copy so the user lands on it, same as // addPage switching to the freshly created page. loadState(copy.craftState, EMPTY_CANVAS); setActivePageId(copyId); activePageIdRef.current = copyId; }, [query, saveCurrentState, loadState], ); /** * Reorders `pageId` one slot up or down (swap with the adjacent page). * Pure list-order change -- does not touch the live canvas. See the doc * comment on `PageContextValue.movePage`. */ const movePage = useCallback((pageId: string, direction: 'up' | 'down') => { setPages((prev) => { const idx = prev.findIndex((p) => p.id === pageId); if (idx === -1) return prev; const swapIdx = direction === 'up' ? idx - 1 : idx + 1; if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends const next = [...prev]; [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; return applyLandingInvariant(next); }); }, []); /** * Moves `pageId` to index 0, making it the new landing page. Pure list- * order change -- does not touch the live canvas. See the doc comment on * `PageContextValue.setLandingPage`. */ const setLandingPage = useCallback((pageId: string) => { setPages((prev) => { const idx = prev.findIndex((p) => p.id === pageId); if (idx <= 0) return prev; // already the landing page, or not found const next = [...prev]; const [moved] = next.splice(idx, 1); next.unshift(moved); return applyLandingInvariant(next); }); }, []); const renamePage = useCallback((pageId: string, name: string, slug: string) => { setPages((prev) => prev.map((p, i) => { if (p.id !== pageId) return p; // First page is the landing page — its slug is locked to 'index' so // the file always publishes to index.html regardless of the user-set // name. The display name can change freely. if (i === 0) return { ...p, name, slug: 'index' }; const requestedSlug = slug || slugify(name); const otherSlugs = prev.filter((pp) => pp.id !== pageId).map((pp) => pp.slug); return { ...p, name, slug: uniqueSlug(requestedSlug, otherSlugs) }; }), ); }, []); /** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */ const updatePageSeo = useCallback((pageId: string, seo: Partial) => { setPages((prev) => prev.map((p) => (p.id === pageId ? { ...p, seo: { ...p.seo, ...seo } } : 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 })); }, []); /** * Bookkeeping-only setter for `activePageId` -- see the doc comment on * `PageContextValue.setActivePageIdDirect`. Does NOT serialize/deserialize * the canvas; callers that need that should use `switchPage` instead. */ const setActivePageIdDirect = useCallback((pageId: string) => { setActivePageId(pageId); activePageIdRef.current = pageId; }, []); /** 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; seo?: PageSeo }[]) => { setPages(pagesData.map((p, i) => ({ id: p.id, name: p.name, // Heal legacy projects whose first page was saved with slug='home' (or // any other) before the landing-page rule existed. The first page is // ALWAYS the landing page → slug 'index' → file index.html. slug: i === 0 ? 'index' : p.slug, craftState: p.craftState, seo: p.seo, }))); }, []); /** * 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; // Track slugs as they're assigned so later pages dedupe against earlier // ones in the same batch (e.g. the AI generating two "About" pages). const seenSlugs: string[] = []; const built = newPages.map((p, i) => { // First page must publish to index.html so it serves at the site root. // Apache resolves '/' to index.html, not home.html — without this, the // AI's "Home" page lands at /home.html and visitors hit a blank root. const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs); seenSlugs.push(slug); return { id: i === 0 ? 'home' : nextPageId(), name: p.name, slug, craftState: treeToCraftState(p.tree), }; }); 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); }, [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 = treeToCraftState(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); }, [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 = treeToCraftState(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); } }, [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 = treeToCraftState(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); } }, [loadState]); return ( {children} ); };