diff --git a/craft/src/hooks/useWhpApi.load.test.tsx b/craft/src/hooks/useWhpApi.load.test.tsx new file mode 100644 index 0000000..8b20d7b --- /dev/null +++ b/craft/src/hooks/useWhpApi.load.test.tsx @@ -0,0 +1,159 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { EditorConfigProvider } from '../state/EditorConfigContext'; +import { PageProvider, usePages } from '../state/PageContext'; +import { SiteDesignProvider } from '../state/SiteDesignContext'; +import { useWhpApi } from './useWhpApi'; +import { WhpConfig } from '../types'; + +/** + * PKG-H §5 round-trip coverage: `load()` must restore per-page `seo` + * (PageSeo) from `proj.pages_craft_state[].seo` back onto the reconstructed + * `PageData`, exactly like it already restores `craftState`. Mocks + * `@craftjs/core`'s `useEditor` (same pattern as + * `PageContext.pure-updaters.test.tsx`) since this test only needs + * `query.serialize`/`actions.deserialize` as inert stubs -- it drives + * `load()`, not the live canvas. + */ +const deserializeMock = vi.fn(); +vi.mock('@craftjs/core', () => ({ + useEditor: () => ({ + query: { serialize: () => '{}' }, + actions: { deserialize: deserializeMock }, + }), +})); + +const whpConfig: WhpConfig = { + user: 'testuser', + apiUrl: '/panel/api/site-builder', + csrfToken: 'tok', + siteId: 42, + siteDomain: 'example.com', + siteName: 'Test Site', + backUrl: '/panel/sites', + isRoot: false, +}; + +let container: HTMLDivElement; +let root: Root; + +interface Captured { + load: ReturnType['load']; + pages: ReturnType['pages']; +} + +function render(): { get: () => Captured } { + container = document.createElement('div'); + document.body.appendChild(container); + let captured: Captured | null = null; + + const Consumer: React.FC = () => { + const { load } = useWhpApi(); + const { pages } = usePages(); + captured = { load, pages }; + return null; + }; + + act(() => { + root = createRoot(container); + root.render( + + + + + + + , + ); + }); + + return { get: () => captured! }; +} + +function unmount() { + act(() => { + root.unmount(); + }); + container.remove(); +} + +describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => { + beforeEach(() => { + deserializeMock.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('a saved project with per-page seo restores seo onto the reconstructed PageData', async () => { + const seoPayload = { + metaTitle: 'Custom Title', + metaDescription: 'A custom description.', + ogTitle: 'Custom OG Title', + ogImage: '/uploads/og.jpg', + twitterCard: 'summary_large_image' as const, + noindex: true, + }; + + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + project: { + design: null, + header_craft_state: null, + footer_craft_state: null, + pages_craft_state: [ + { id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}', seo: seoPayload }, + { id: 'page_2', name: 'About', slug: 'about', craftState: '{"ROOT":{}}' }, + ], + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const harness = render(); + + await act(async () => { + await harness.get().load(); + }); + + const { pages } = harness.get(); + expect(pages.find((p) => p.id === 'home')?.seo).toEqual(seoPayload); + // A page with no seo in the payload stays undefined -- back-compat, not + // coerced into an empty object. + expect(pages.find((p) => p.id === 'page_2')?.seo).toBeUndefined(); + + unmount(); + }); + + test('a legacy project with no seo on any page loads without adding seo fields', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + success: true, + project: { + design: null, + header_craft_state: null, + footer_craft_state: null, + pages_craft_state: [ + { id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }, + ], + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const harness = render(); + + await act(async () => { + await harness.get().load(); + }); + + const { pages } = harness.get(); + expect(pages.find((p) => p.id === 'home')?.seo).toBeUndefined(); + + unmount(); + }); +}); diff --git a/craft/src/hooks/useWhpApi.save.test.ts b/craft/src/hooks/useWhpApi.save.test.ts index 41c3975..ab32398 100644 --- a/craft/src/hooks/useWhpApi.save.test.ts +++ b/craft/src/hooks/useWhpApi.save.test.ts @@ -147,4 +147,63 @@ describe('buildSavePayload', () => { expect(payload.design).toEqual(design); expect(payload.design.headCode).toBe(''); }); + + test('PKG-H §5: per-page seo is included in both pages_craft_state and pages entries', () => { + const pageWithSeo: PageData = { + id: 'home', + name: 'Home', + slug: 'index', + craftState: 'STORED_HOME', + seo: { + metaTitle: 'Custom Title', + metaDescription: 'Custom description', + ogTitle: 'Custom OG', + ogImage: '/uploads/og.jpg', + twitterCard: 'summary_large_image', + noindex: true, + }, + }; + + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_PAGE_HOME', + pages: [pageWithSeo, pageB], + headerPage, + footerPage, + activePageId: 'home', + isEditingHeader: false, + isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, + }); + + expect(payload.pages_craft_state.find((p) => p.id === 'home')?.seo).toEqual(pageWithSeo.seo); + expect(payload.pages.find((p) => p.filename === 'index.html')?.seo).toEqual(pageWithSeo.seo); + + // A page with no seo overrides omits the field entirely (undefined), + // not an empty object -- back-compat with pre-PKG-H saved shapes. + expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.seo).toBeUndefined(); + expect(payload.pages.find((p) => p.filename === 'about.html')?.seo).toBeUndefined(); + }); + + test('PKG-H §5: favicon flows through the design object already carried by the payload', () => { + const design = { ...DEFAULT_SITE_DESIGN, favicon: '/uploads/favicon.png' }; + + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_PAGE_HOME', + pages: [pageA, pageB], + headerPage, + footerPage, + activePageId: 'home', + isEditingHeader: false, + isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design, + }); + + expect(payload.design.favicon).toBe('/uploads/favicon.png'); + }); }); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 464333b..33e0fc9 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -149,6 +149,12 @@ export function buildSavePayload(input: BuildSavePayloadInput) { filename, title: page.name, html: pageHtml, + // PKG-H §5: per-page SEO overrides, read by the backend's + // `handlePublish` (mirrors the existing `title` field above) to build + // the published for this file. Omitted (undefined) when the + // page has no seo overrides -- JSON.stringify drops undefined keys, + // so legacy-shaped payloads round-trip unchanged. + seo: page.seo, }; }); @@ -166,6 +172,9 @@ export function buildSavePayload(input: BuildSavePayloadInput) { // 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), + // PKG-H §5: round-trips PageData.seo through save -> load() so the + // editor's SEO fields survive a reload (mirrors craftState above). + seo: page.seo, })); return { @@ -292,8 +301,8 @@ export function useWhpApi() { // 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, + setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo'] }) => ({ + id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo, }))); // Load the first page (home) into the canvas diff --git a/craft/src/panels/left/PageSettingsModal.tsx b/craft/src/panels/left/PageSettingsModal.tsx new file mode 100644 index 0000000..4771974 --- /dev/null +++ b/craft/src/panels/left/PageSettingsModal.tsx @@ -0,0 +1,234 @@ +import React, { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Modal } from '../../ui/Modal'; +import { AssetPicker } from '../../ui/AssetPicker'; +import { PageData, PageSeo } from '../../types'; + +interface PageSettingsModalProps { + open: boolean; + onClose: () => void; + page: PageData | null; + updatePageSeo: (pageId: string, seo: Partial) => void; +} + +const EMPTY_SEO: PageSeo = { + metaTitle: '', + metaDescription: '', + ogTitle: '', + ogImage: '', + twitterCard: 'summary_large_image', + noindex: false, +}; + +/** + * Per-page SEO/meta settings (PKG-H contract §1/§4). Opened via the gear + * icon on a page row in PagesPanel. Fields map 1:1 onto `PageData.seo` + * (`types/index.ts`) and are consumed by `html-export.ts`'s `wrapInDocument` + * (Preview) and the backend's `generateCompiledHTML` (published output) -- + * field names here MUST match the shared contract exactly. + * + * Follows the HeadCodeModal precedent: portaled to `document.body` (escapes + * the topbar/left-panel's own stacking context, same fix HeadCodeModal and + * TemplateModal already needed), built on the shared `Modal` shell, edits + * commit immediately via `updatePageSeo` (no separate Save step) rather than + * buffering to a "Save" button -- consistent with HeadCodeModal's live-edit + * pattern for `design.headCode`. + */ +export const PageSettingsModal: React.FC = ({ open, onClose, page, updatePageSeo }) => { + const [seo, setSeo] = useState(EMPTY_SEO); + + // Re-sync local form state from the target page's stored seo whenever the + // modal opens (or the target page changes while open) -- mirrors the + // pattern of resetting form-local state on prop identity change rather + // than deriving directly from props (dropdowns/checkboxes need editable + // local state). + useEffect(() => { + if (!open) return; + setSeo({ ...EMPTY_SEO, ...(page?.seo || {}) }); + }, [open, page?.id, page?.seo]); + + if (!page) return null; + + const update = (patch: Partial) => { + setSeo((prev) => ({ ...prev, ...patch })); + updatePageSeo(page.id, patch); + }; + + return createPortal( + +
e.stopPropagation()}> + {/* Header */} +
+
+ +
+
+ Page Settings — {page.name} +
+
+ SEO and social-sharing metadata for this page only. +
+
+
+ +
+ + {/* Body */} +
+
+ + update({ metaTitle: e.target.value })} + placeholder={page.name} + data-testid="page-seo-meta-title" + /> +

Overrides the browser tab title. Leave blank to use the page name.

+
+ +
+ +