import { useCallback } from 'react'; import { useEditor } from '@craftjs/core'; import { useEditorConfig } from '../state/EditorConfigContext'; import { usePages } from '../state/PageContext'; import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext'; import { exportBodyHtml } from '../utils/html-export'; import { PageData } from '../types'; export interface BuildSavePayloadInput { siteId: number; siteName: string; /** query.serialize() of whatever is currently on the live canvas. */ liveCraftState: string; pages: PageData[]; headerPage: PageData; footerPage: PageData; activePageId: string; /** True when the live canvas is showing the header zone (activePageId === '__header__'). */ isEditingHeader: boolean; /** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */ isEditingFooter: boolean; /** Site-wide custom `` code (also present on `design.headCode`). */ headCode: string; /** Full site design tokens object -- lets load() restore colors/fonts/headCode. */ design: SiteDesign; } /** * Pure payload builder for the save() API call. Extracted so the * header/footer-vs-page routing logic can be unit-tested without mounting * React/Craft.js. * * Bug this fixes: when the user is editing the Header or Footer, * `activePageId` is `'__header__'`/`'__footer__'` — which matches no entry * in `pages`. The live canvas serialization must be routed into * `header_craft_state`/`footer_craft_state` in that case, NOT into a page * slot or the top-level `craft_state`/`html` (which must always represent * an actual page). Conversely, header/footer must be sourced from the FRESH * live state when that zone is being edited, not from the stale stored * `headerPage.craftState`/`footerPage.craftState`. */ export function buildSavePayload(input: BuildSavePayloadInput) { const { siteId, siteName, liveCraftState, pages, headerPage, footerPage, activePageId, isEditingHeader, isEditingFooter, headCode, design, } = input; const isPageActive = !isEditingHeader && !isEditingFooter; // I-1 (data-loss): `activePageId` can go dangling -- e.g. the original // Home page (id 'home') is deleted, its replacement gets a fresh id // (`page_`), and a reload re-initializes `activePageId` back to the // hardcoded default `'home'` (see PageContext's `useState('home')`) before // `load()` has a chance to point it at the actually-restored page. If a // real page IS active but matches no entry in `pages`, treat `pages[0]` // (the landing page) as the active one so the live canvas serialization // still reaches the index.html page slot / `pages_craft_state[0]` instead // of only the legacy top-level `craft_state`/`html` fields. const activePageIndex = isPageActive ? pages.findIndex((p) => p.id === activePageId) : -1; const effectiveActivePageId = activePageIndex !== -1 ? activePageId : pages[0]?.id; // Fresh header/footer state: the live canvas wins when that zone is the // one currently being edited; otherwise fall back to the last-committed // stored state (updated on zone switch by PageContext's saveCurrentState). const headerCraftState = isEditingHeader ? liveCraftState : (headerPage.craftState || null); const footerCraftState = isEditingFooter ? liveCraftState : (footerPage.craftState || null); let headerHtml = ''; try { if (headerCraftState) { headerHtml = exportBodyHtml(headerCraftState).html; } } catch (e) { console.error('Header HTML export failed:', e); } let footerHtml = ''; try { if (footerCraftState) { footerHtml = exportBodyHtml(footerCraftState).html; } } catch (e) { console.error('Footer HTML export failed:', e); } // The top-level `craft_state`/`html` fields (and the matching per-page // slot below) must always represent an actual PAGE. When editing the // header/footer, activePageId matches no page — use the landing page's // own stored state instead of leaking the live header/footer canvas into // a page slot or mislabeling it as page content. let currentCraftState: string | null; let currentHtml = ''; let css = ''; if (isPageActive) { currentCraftState = liveCraftState; try { const result = exportBodyHtml(currentCraftState); currentHtml = result.html; css = result.css; } catch (e) { console.error('HTML export failed, saving state only:', e); } } else { const landingPage = pages[0] ?? null; currentCraftState = landingPage?.craftState ?? null; try { if (currentCraftState) { const result = exportBodyHtml(currentCraftState); currentHtml = result.html; css = result.css; } } catch (e) { console.error('HTML export failed, saving state only:', e); } } // Build the pages array with HTML for each page. For the active page (only // possible when a real page is active), use the freshly exported HTML from // the canvas; for others, export from their stored craft state. const pagesPayload = pages.map((page, i) => { // The first page is ALWAYS the landing page → publishes to index.html // regardless of the page name/slug. Apache serves '/' from index.html, // and renaming the first page should not break the root URL. const filename = i === 0 ? 'index.html' : page.slug + '.html'; let pageHtml = ''; if (isPageActive && page.id === effectiveActivePageId) { // Active page: use the current canvas HTML (already exported above) pageHtml = currentHtml; } else if (page.craftState) { try { pageHtml = exportBodyHtml(page.craftState).html; } catch (e) { console.error(`HTML export failed for page ${page.name}:`, e); } } return { filename, title: page.name, html: pageHtml, }; }); // Build pages_craft_state array: for each page, store its craft state. // For the currently active page (only when a real page is active), always // use the fresh canvas state since page.craftState may be stale (not // updated until page switch). When editing header/footer, activePageId // matches no page, so every page correctly falls back to its own stored // state below. const pagesGrapesjs = pages.map((page, i) => ({ id: page.id, name: page.name, // Pin the landing page's slug to 'index' on the wire too, so that on // reload the editor's clean-URL routing (.htaccess rewrite of /name → // name.html) lines up with the file we just wrote (index.html). slug: i === 0 ? 'index' : page.slug, craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null), })); return { site_id: siteId, name: siteName, html: currentHtml, css, pages: pagesPayload, header_html: headerHtml, footer_html: footerHtml, craft_state: currentCraftState, header_craft_state: headerCraftState, footer_craft_state: footerCraftState, pages_craft_state: pagesGrapesjs, head_code: headCode, design, }; } export function useWhpApi() { const { query, actions } = useEditor(); const { whpConfig, isWHP } = useEditorConfig(); const { pages, headerPage, footerPage, activePageId, isEditingHeader, isEditingFooter, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, } = usePages(); const { design, updateDesign } = useSiteDesign(); const save = useCallback(async () => { if (!isWHP || !whpConfig) return null; // Serialize whatever is currently on the live canvas (a page, the // header, or the footer — depending on activePageId/isEditingHeader/ // isEditingFooter). const liveCraftState = query.serialize(); const payload = buildSavePayload({ siteId: whpConfig.siteId, siteName: whpConfig.siteName, liveCraftState, pages, headerPage, footerPage, activePageId, isEditingHeader, isEditingFooter, headCode: design.headCode, design, }); const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken, }, body: JSON.stringify(payload), }); return resp.json(); }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]); const publish = useCallback(async () => { if (!isWHP || !whpConfig) return null; // First save to ensure staging is up to date await save(); // Then publish from staging to live const resp = await fetch( `${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken, }, body: JSON.stringify({ site_id: whpConfig.siteId }), }, ); return resp.json(); }, [isWHP, whpConfig, save]); const load = useCallback(async () => { if (!isWHP || !whpConfig) return null; const resp = await fetch( `${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`, ); const data = await resp.json(); if (data.success && data.project) { const proj = data.project; // Restore site design tokens (colors/fonts/headCode) so the editor // reflects what was last saved. Prefer the full `design` object when // present; fall back to just `head_code` for older project.json files // saved before this field existed (backward-compatible: defaults for // everything else). if (proj.design && typeof proj.design === 'object') { updateDesign(proj.design); } else if (typeof proj.head_code === 'string') { updateDesign({ headCode: proj.head_code }); } // Restore header craft state if (proj.header_craft_state) { setHeaderCraftState(typeof proj.header_craft_state === 'string' ? proj.header_craft_state : JSON.stringify(proj.header_craft_state)); } // Restore footer craft state if (proj.footer_craft_state) { setFooterCraftState(typeof proj.footer_craft_state === 'string' ? proj.footer_craft_state : JSON.stringify(proj.footer_craft_state)); } // Restore pages and load the first page into the canvas if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) { setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null }) => ({ id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, }))); // Load the first page (home) into the canvas const firstPage = proj.pages_craft_state[0]; if (firstPage.craftState) { try { const state = typeof firstPage.craftState === 'string' ? firstPage.craftState : JSON.stringify(firstPage.craftState); actions.deserialize(state); } catch (e) { console.warn('Failed to load page state:', e); } } // I-1 (data-loss): point activePageId at the page we just loaded // into the canvas. Without this, activePageId stays at whatever it // was initialized to (the hardcoded default 'home'), which goes // dangling the moment the original Home page has been deleted and // replaced (its replacement gets a fresh `page_` id) -- the next // edit+save would then only reach the legacy top-level fields // instead of the actual page slot. Only do this when a real page is // being loaded, i.e. we're not currently mid-edit of the header/ // footer zone (switching zones is handled separately by switchPage). if (!isEditingHeader && !isEditingFooter) { setActivePageIdDirect(firstPage.id); } } } return data; }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]); const uploadAsset = useCallback( async (file: File) => { if (!isWHP || !whpConfig) return null; const formData = new FormData(); formData.append('file', file); const resp = await fetch( `${whpConfig.apiUrl}?action=upload_asset&site_id=${whpConfig.siteId}`, { method: 'POST', headers: { 'X-CSRF-Token': whpConfig.csrfToken }, body: formData, }, ); return resp.json(); }, [isWHP, whpConfig], ); return { save, publish, load, uploadAsset, isWHP }; }