From 92841e3f355a965c2d413061fce77145a57ef6ca Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 18:22:38 -0700 Subject: [PATCH] feat(builder): send + restore site head code in save/load Extend the save payload with head_code + design so the backend can inject SiteDesign.headCode into published pages, and restore design tokens on load() so the editor reflects the last-saved state. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/hooks/useWhpApi.save.test.ts | 31 ++++++++++++++++++++++++++ craft/src/hooks/useWhpApi.ts | 27 ++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/craft/src/hooks/useWhpApi.save.test.ts b/craft/src/hooks/useWhpApi.save.test.ts index 96ef8ef..41c3975 100644 --- a/craft/src/hooks/useWhpApi.save.test.ts +++ b/craft/src/hooks/useWhpApi.save.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import { buildSavePayload } from './useWhpApi'; import { PageData } from '../types'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME' }; const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT' }; @@ -19,6 +20,8 @@ describe('buildSavePayload', () => { activePageId: '__header__', isEditingHeader: true, isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); // The fresh live canvas must be reflected in header_craft_state, not the stale stored one. @@ -52,6 +55,8 @@ describe('buildSavePayload', () => { activePageId: '__footer__', isEditingHeader: false, isEditingFooter: true, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); expect(payload.footer_craft_state).toBe('LIVE_FOOTER'); @@ -75,6 +80,8 @@ describe('buildSavePayload', () => { activePageId: 'home', isEditingHeader: false, isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); // Live canvas goes to the active page and top-level slots. @@ -105,6 +112,8 @@ describe('buildSavePayload', () => { activePageId: 'home', isEditingHeader: false, isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); // The live edit must land in pages_craft_state[0] (index.html slot), @@ -116,4 +125,26 @@ describe('buildSavePayload', () => { // The other page is untouched. expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2'); }); + + test('head code + design tokens are included in the save payload', () => { + const design = { ...DEFAULT_SITE_DESIGN, headCode: '' }; + + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_PAGE_HOME', + pages: [pageA, pageB], + headerPage, + footerPage, + activePageId: 'home', + isEditingHeader: false, + isEditingFooter: false, + headCode: '', + design, + }); + + expect(payload.head_code).toBe(''); + expect(payload.design).toEqual(design); + expect(payload.design.headCode).toBe(''); + }); }); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 2319c4d..464333b 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -2,6 +2,7 @@ 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'; @@ -18,6 +19,10 @@ export interface BuildSavePayloadInput { 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; } /** @@ -45,6 +50,8 @@ export function buildSavePayload(input: BuildSavePayloadInput) { activePageId, isEditingHeader, isEditingFooter, + headCode, + design, } = input; const isPageActive = !isEditingHeader && !isEditingFooter; @@ -173,6 +180,8 @@ export function buildSavePayload(input: BuildSavePayloadInput) { header_craft_state: headerCraftState, footer_craft_state: footerCraftState, pages_craft_state: pagesGrapesjs, + head_code: headCode, + design, }; } @@ -191,6 +200,7 @@ export function useWhpApi() { setPagesCraftState, setActivePageIdDirect, } = usePages(); + const { design, updateDesign } = useSiteDesign(); const save = useCallback(async () => { if (!isWHP || !whpConfig) return null; @@ -210,6 +220,8 @@ export function useWhpApi() { activePageId, isEditingHeader, isEditingFooter, + headCode: design.headCode, + design, }); const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { @@ -221,7 +233,7 @@ export function useWhpApi() { body: JSON.stringify(payload), }); return resp.json(); - }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]); + }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]); const publish = useCallback(async () => { if (!isWHP || !whpConfig) return null; @@ -255,6 +267,17 @@ export function useWhpApi() { 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' @@ -300,7 +323,7 @@ export function useWhpApi() { } } return data; - }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]); + }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]); const uploadAsset = useCallback( async (file: File) => {