2026-04-05 18:31:16 -07:00
|
|
|
import { useCallback } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
|
|
|
|
import { useEditorConfig } from '../state/EditorConfigContext';
|
|
|
|
|
import { usePages } from '../state/PageContext';
|
2026-07-12 18:22:38 -07:00
|
|
|
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
2026-04-05 18:31:16 -07:00
|
|
|
import { exportBodyHtml } from '../utils/html-export';
|
2026-07-12 12:28:38 -07:00
|
|
|
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;
|
2026-07-12 18:22:38 -07:00
|
|
|
/** Site-wide custom `<head>` code (also present on `design.headCode`). */
|
|
|
|
|
headCode: string;
|
|
|
|
|
/** Full site design tokens object -- lets load() restore colors/fonts/headCode. */
|
|
|
|
|
design: SiteDesign;
|
2026-07-12 12:28:38 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-07-12 18:22:38 -07:00
|
|
|
headCode,
|
|
|
|
|
design,
|
2026-07-12 12:28:38 -07:00
|
|
|
} = input;
|
|
|
|
|
|
|
|
|
|
const isPageActive = !isEditingHeader && !isEditingFooter;
|
|
|
|
|
|
2026-07-12 17:29:32 -07:00
|
|
|
// 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_<ts>`), 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;
|
|
|
|
|
|
2026-07-12 12:28:38 -07:00
|
|
|
// 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;
|
2026-04-05 18:31:16 -07:00
|
|
|
try {
|
|
|
|
|
const result = exportBodyHtml(currentCraftState);
|
|
|
|
|
currentHtml = result.html;
|
|
|
|
|
css = result.css;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('HTML export failed, saving state only:', e);
|
|
|
|
|
}
|
2026-07-12 12:28:38 -07:00
|
|
|
} else {
|
|
|
|
|
const landingPage = pages[0] ?? null;
|
|
|
|
|
currentCraftState = landingPage?.craftState ?? null;
|
2026-04-05 18:31:16 -07:00
|
|
|
try {
|
2026-07-12 12:28:38 -07:00
|
|
|
if (currentCraftState) {
|
|
|
|
|
const result = exportBodyHtml(currentCraftState);
|
|
|
|
|
currentHtml = result.html;
|
|
|
|
|
css = result.css;
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-07-12 12:28:38 -07:00
|
|
|
console.error('HTML export failed, saving state only:', e);
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
2026-07-12 12:28:38 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 = '';
|
|
|
|
|
|
2026-07-12 17:29:32 -07:00
|
|
|
if (isPageActive && page.id === effectiveActivePageId) {
|
2026-07-12 12:28:38 -07:00
|
|
|
// 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);
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 12:28:38 -07:00
|
|
|
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,
|
2026-07-12 17:29:32 -07:00
|
|
|
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
|
2026-07-12 12:28:38 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-12 18:22:38 -07:00
|
|
|
head_code: headCode,
|
|
|
|
|
design,
|
2026-07-12 12:28:38 -07:00
|
|
|
};
|
|
|
|
|
}
|
2026-04-05 18:31:16 -07:00
|
|
|
|
2026-07-12 12:28:38 -07:00
|
|
|
export function useWhpApi() {
|
|
|
|
|
const { query, actions } = useEditor();
|
|
|
|
|
const { whpConfig, isWHP } = useEditorConfig();
|
|
|
|
|
const {
|
|
|
|
|
pages,
|
|
|
|
|
headerPage,
|
|
|
|
|
footerPage,
|
|
|
|
|
activePageId,
|
|
|
|
|
isEditingHeader,
|
|
|
|
|
isEditingFooter,
|
|
|
|
|
setHeaderCraftState,
|
|
|
|
|
setFooterCraftState,
|
|
|
|
|
setPagesCraftState,
|
2026-07-12 17:29:32 -07:00
|
|
|
setActivePageIdDirect,
|
2026-07-12 12:28:38 -07:00
|
|
|
} = usePages();
|
2026-07-12 18:22:38 -07:00
|
|
|
const { design, updateDesign } = useSiteDesign();
|
2026-04-05 18:31:16 -07:00
|
|
|
|
2026-07-12 12:28:38 -07:00
|
|
|
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,
|
2026-07-12 18:22:38 -07:00
|
|
|
headCode: design.headCode,
|
|
|
|
|
design,
|
2026-07-12 12:28:38 -07:00
|
|
|
});
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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();
|
2026-07-12 18:22:38 -07:00
|
|
|
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-07-12 18:22:38 -07:00
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-12 17:29:32 -07:00
|
|
|
|
|
|
|
|
// 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_<ts>` 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);
|
|
|
|
|
}
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return data;
|
2026-07-12 18:22:38 -07:00
|
|
|
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]);
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|