diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx index 24b1a12..8c74cd2 100644 --- a/craft/src/editor/CanvasOverlays.tsx +++ b/craft/src/editor/CanvasOverlays.tsx @@ -7,6 +7,7 @@ import { CommandPalette } from '../panels/command-palette/CommandPalette'; import { HistoryHost } from '../panels/history/HistoryModal'; import { SpacingHandles } from './SpacingHandles'; import { SectionLibraryModal } from '../panels/sections/SectionLibraryModal'; +import { ProfileWizardHost } from '../panels/sitesmith/ProfileWizard'; /** * Single mount point for editor-wide overlays rendered next to the shell. @@ -23,5 +24,6 @@ export const CanvasOverlays: React.FC = () => ( + ); diff --git a/craft/src/hooks/useSitesmith.test.ts b/craft/src/hooks/useSitesmith.test.ts new file mode 100644 index 0000000..06eba11 --- /dev/null +++ b/craft/src/hooks/useSitesmith.test.ts @@ -0,0 +1,81 @@ +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 { SaveGuardProvider } from '../state/SaveGuardContext'; +import { ProjectExtrasProvider, useProjectExtras } from '../state/ProjectExtrasContext'; +import { useSitesmith } from './useSitesmith'; + +const CFG = { user: 'u', apiUrl: '/api/site-builder.php', csrfToken: 't', siteId: 7, siteDomain: 'x.com', siteName: 'x', backUrl: '/', isRoot: false }; +const PROFILE = { business_name: 'Acme', business_type: 'Plumber', services: ['Drains'], area: 'Austin', tone: 'bold' }; + +let api: ReturnType; +let extras: ReturnType; +let root: Root; +let container: HTMLDivElement; +let sends: any[]; +let entitlement: any; + +const Probe: React.FC = () => { api = useSitesmith(7); extras = useProjectExtras(); return null; }; + +beforeEach(async () => { + sends = []; + entitlement = { ok: true, summary: { enabled: true, status: 'OK_MONTHLY' } }; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => { + const json = (v: unknown) => ({ json: async () => v }); + if (url.includes('action=send')) { sends.push(JSON.parse(init.body)); return json({ ok: true, response: { type: 'ask', question: 'q' } }); } + if (url.includes('action=entitlement')) return json(entitlement); + return json({ ok: true, messages: [] }); + })); + container = document.createElement('div'); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container); + root.render( + React.createElement(EditorConfigProvider as any, { config: CFG }, + React.createElement(SaveGuardProvider as any, null, + React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))), + ); + }); +}); +afterEach(() => { act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); + +describe('useSitesmith send body', () => { + test('omits profile and intent when none is stored or given', async () => { + await act(async () => { await api.send('hello', 'canvas'); }); + expect(sends).toHaveLength(1); + expect(sends[0]).not.toHaveProperty('profile'); + expect(sends[0]).not.toHaveProperty('intent'); + expect(sends[0].message).toBe('hello'); + }); + + test('includes the stored profile and the intent', async () => { + act(() => extras.set('sitesmithProfile', PROFILE)); + await act(async () => { await api.send('regen', 'canvas', undefined, { intent: 'regenerate_section' }); }); + expect(sends[0].profile).toEqual(PROFILE); + expect(sends[0].intent).toBe('regenerate_section'); + }); + + test('an incomplete stored profile is not sent', async () => { + act(() => extras.set('sitesmithProfile', { ...PROFILE, services: [] })); + await act(async () => { await api.send('hello', 'canvas'); }); + expect(sends[0]).not.toHaveProperty('profile'); + }); + + test('old backend (no profile_supported flag): the business line rides in the message', async () => { + act(() => extras.set('sitesmithProfile', PROFILE)); + await act(async () => { await api.send('hello', 'canvas'); }); + expect(sends[0].message).toContain('Acme is a Plumber'); + expect(sends[0].message).toContain('hello'); + }); + + test('backend that reports profile_supported keeps the message untouched', async () => { + entitlement = { ok: true, summary: { enabled: true, status: 'OK_MONTHLY' }, profile_supported: true }; + await act(async () => { await api.refreshEntitlement(); }); + act(() => extras.set('sitesmithProfile', PROFILE)); + await act(async () => { await api.send('hello', 'canvas'); }); + expect(sends[0].message).toBe('hello'); + expect(sends[0].profile).toEqual(PROFILE); + }); +}); diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index e059d9a..6d5dc58 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -1,5 +1,7 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useEditorConfig } from '../state/EditorConfigContext'; +import { useProjectExtras } from '../state/ProjectExtrasContext'; +import { PROFILE_EXTRAS_KEY, isProfileComplete, normalizeProfile, profileToPrompt } from '../utils/sitesmith-profile'; import { SitesmithSummary, SitesmithMessage, SendResult } from '../types/sitesmith'; function apiBase(apiUrl: string): string { @@ -8,6 +10,9 @@ function apiBase(apiUrl: string): string { export function useSitesmith(siteId: number) { const { whpConfig } = useEditorConfig(); + const extras = useProjectExtras(); + // Set from the entitlement response once the backend says it understands `profile`. + const profileSupported = useRef(false); const [summary, setSummary] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); @@ -18,7 +23,7 @@ export function useSitesmith(siteId: number) { try { const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=entitlement`, { credentials: 'include' }); const j = await r.json(); - if (j.ok) setSummary(j.summary); + if (j.ok) { setSummary(j.summary); profileSupported.current = j.profile_supported === true; } } catch (e: any) { setError(String(e?.message ?? e)); } }, [whpConfig]); @@ -38,11 +43,20 @@ export function useSitesmith(siteId: number) { userText: string, canvasSummary: string, target?: { node_id: string; display_name: string; tree_json: string }, + opts?: { intent?: 'regenerate_section' }, ): Promise => { if (!whpConfig) return { ok: false, status: 'BLOCKED', message: 'No WHP config' }; setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]); - const body: Record = { site_id: siteId, message: userText, canvas_summary: canvasSummary }; + // Read at send time (extras live in a ref), so a profile saved a moment ago is included. + const stored = normalizeProfile(extras.get(PROFILE_EXTRAS_KEY, null)); + const profile = stored && isProfileComplete(stored) ? stored : null; + // A backend that predates `profile` ignores the key, so until it says otherwise + // the business line also rides in the message. + const message = profile && !profileSupported.current ? `${profileToPrompt(profile)} ${userText}` : userText; + const body: Record = { site_id: siteId, message, canvas_summary: canvasSummary }; if (target) body.target = target; + if (profile) body.profile = profile; + if (opts?.intent) body.intent = opts.intent; const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=send`, { method: 'POST', credentials: 'include', @@ -53,7 +67,7 @@ export function useSitesmith(siteId: number) { void fetchHistory(); void refreshEntitlement(); return j; - }, [whpConfig, siteId, fetchHistory, refreshEntitlement]); + }, [whpConfig, siteId, extras, fetchHistory, refreshEntitlement]); const clearHistory = useCallback(async (): Promise<{ ok: boolean; cleared?: number; error?: string }> => { if (!whpConfig) return { ok: false, error: 'No WHP config' }; diff --git a/craft/src/panels/right/SiteDesignPanel.tsx b/craft/src/panels/right/SiteDesignPanel.tsx index bf04ae9..672b75b 100644 --- a/craft/src/panels/right/SiteDesignPanel.tsx +++ b/craft/src/panels/right/SiteDesignPanel.tsx @@ -7,6 +7,7 @@ import { SerializedTreeNode } from '../../types/sitesmith'; import { FONT_FAMILIES } from '../../constants/presets'; import { AssetPicker } from '../../ui/AssetPicker'; import { inputStyle, ColorPickerField } from './styles/shared'; +import { openProfileWizard } from '../sitesmith/ProfileWizard'; import { PALETTES, paletteUpdates, paletteMatches } from '../../constants/palettes'; type DesignTab = 'basic' | 'advanced'; @@ -274,6 +275,13 @@ export const SiteDesignPanel: React.FC = () => { {tab === 'basic' && ( <> + {whpConfig && ( +
+ +
+ )}
void; } +interface Props { disabled?: boolean; placeholder?: string; onSend: (text: string) => void; /** Starting text, e.g. a suggested first message. */ initialValue?: string; } -export const ChatInput: React.FC = ({ disabled, placeholder, onSend }) => { - const [v, setV] = useState(''); +export const ChatInput: React.FC = ({ disabled, placeholder, onSend, initialValue }) => { + const [v, setV] = useState(initialValue ?? ''); const fire = () => { const t = v.trim(); if (!t || disabled) return; onSend(t); setV(''); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fire(); } }; return ( diff --git a/craft/src/panels/sitesmith/ProfileWizard.test.tsx b/craft/src/panels/sitesmith/ProfileWizard.test.tsx new file mode 100644 index 0000000..346ce5b --- /dev/null +++ b/craft/src/panels/sitesmith/ProfileWizard.test.tsx @@ -0,0 +1,123 @@ +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 { SaveGuardProvider, useSaveGuard } from '../../state/SaveGuardContext'; +import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext'; +import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext'; +import { PALETTES } from '../../constants/palettes'; +import { ProfileWizard } from './ProfileWizard'; + +let root: Root; +let container: HTMLDivElement; +let seen: { dirty: boolean; extras: ReturnType; primary: string }; +const onDone = vi.fn(); +const onCancel = vi.fn(); + +const Probe: React.FC = () => { + seen = { dirty: useSaveGuard().dirty, extras: useProjectExtras(), primary: useSiteDesign().design.primaryColor }; + return null; +}; + +function mount(initial?: any) { + act(() => { + root.render( + + + + , + ); + }); +} +const q = (sel: string) => document.querySelector(sel) as HTMLElement; +const btn = (label: string) => [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === label) as HTMLButtonElement; +function type(sel: string, value: string) { + const el = q(sel) as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + act(() => { setter.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); }); +} +const click = (el: HTMLElement) => act(() => { el.click(); }); + +/** Walk steps 1-4 with valid answers and land on the palette step. */ +function fillToPalette() { + type('#sw-name', 'Acme Plumbing'); + type('#sw-type', 'Plumber'); + click(btn('Next')); + type('#sw-service', 'Drain cleaning'); + act(() => { q('#sw-service').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); + click(btn('Next')); + type('#sw-area', 'Austin'); + click(btn('Next')); + click(document.querySelector('[role="radio"][aria-checked="false"]') as HTMLElement); // pick a non-default tone + click(btn('Next')); +} + +beforeEach(() => { + onDone.mockReset(); onCancel.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(() => { act(() => root.unmount()); container.remove(); }); + +describe('ProfileWizard', () => { + test('completing the wizard stores the profile in extras and marks the editor dirty', () => { + mount(); + expect(seen.dirty).toBe(false); + fillToPalette(); + click(btn('Finish')); + const stored = seen.extras.get('sitesmithProfile', null); + expect(stored).toMatchObject({ business_name: 'Acme Plumbing', business_type: 'Plumber', services: ['Drain cleaning'], area: 'Austin' }); + expect(stored.tone).not.toBe('friendly'); // the non-default tone we clicked + expect(seen.dirty).toBe(true); + expect(onDone).toHaveBeenCalledWith(expect.objectContaining({ business_name: 'Acme Plumbing' })); + }); + + test('Next is blocked with a message until the required answers are in', () => { + mount(); + click(btn('Next')); + expect(q('[role="alert"]').textContent).toMatch(/business name/i); + expect(q('#sw-name')).not.toBeNull(); // still on step 1 + expect(seen.extras.get('sitesmithProfile', null)).toBeNull(); + }); + + test('a half-typed service is kept when pressing Next', () => { + mount(); + type('#sw-name', 'A'); type('#sw-type', 'B'); + click(btn('Next')); + type('#sw-service', 'Gutter repair'); + click(btn('Next')); + expect(q('#sw-area')).not.toBeNull(); + click(btn('Back')); + expect(q('.sitesmith-wiz-chips').textContent).toContain('Gutter repair'); + }); + + test('palette step previews live (primaryColor changes) and Cancel reverts it, saving nothing', () => { + mount(); + fillToPalette(); + const pal = PALETTES.find((p) => p.design.primaryColor !== DEFAULT_SITE_DESIGN.primaryColor)!; + click(q(`.palette-preview[title="${pal.name}"]`)); + expect(seen.primary).toBe(pal.design.primaryColor); + click(btn('Cancel')); + expect(seen.primary).toBe(DEFAULT_SITE_DESIGN.primaryColor); + expect(seen.extras.get('sitesmithProfile', null)).toBeNull(); + expect(onCancel).toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + }); + + test('"Keep this look" saves the profile and leaves the previewed palette applied', () => { + mount(); + fillToPalette(); + const pal = PALETTES.find((p) => p.design.primaryColor !== DEFAULT_SITE_DESIGN.primaryColor)!; + click(q(`.palette-preview[title="${pal.name}"]`)); + click(btn('Keep this look')); + expect(seen.primary).toBe(pal.design.primaryColor); + expect(seen.extras.get('sitesmithProfile', null)?.business_name).toBe('Acme Plumbing'); + }); + + test('cancel without previewing does not touch the design', () => { + mount(); + click(btn('Cancel')); + expect(seen.primary).toBe(DEFAULT_SITE_DESIGN.primaryColor); + }); +}); diff --git a/craft/src/panels/sitesmith/ProfileWizard.tsx b/craft/src/panels/sitesmith/ProfileWizard.tsx new file mode 100644 index 0000000..b07d15d --- /dev/null +++ b/craft/src/panels/sitesmith/ProfileWizard.tsx @@ -0,0 +1,292 @@ +import React, { useCallback, useRef, useState, useSyncExternalStore } from 'react'; +import { Modal } from '../../ui/Modal'; +import { useProjectExtras } from '../../state/ProjectExtrasContext'; +import { useSiteDesign } from '../../state/SiteDesignContext'; +import { useEditorConfig } from '../../state/EditorConfigContext'; +import { PALETTES, paletteUpdates, paletteMatches } from '../../constants/palettes'; +import { useRegisterCommands } from '../../editor/commands'; +import { buildSitesmithTarget } from '../../utils/sitesmith-target'; +import { + EMPTY_PROFILE, PROFILE_EXTRAS_KEY, TONES, SitesmithProfile, + normalizeProfile, validateProfile, isProfileComplete, +} from '../../utils/sitesmith-profile'; + +/* ---------- Tiny module-level store ---------- + Lets Site Design, the command palette and the Sitesmith modal open the wizard + without a new provider (same approach as editor/commands.ts). */ + +export interface WizardRequest { + /** Runs after the profile is saved. */ + onDone?(profile: SitesmithProfile): void; + /** Runs when the person leaves without saving. */ + onCancel?(): void; + cancelLabel?: string; +} + +let request: WizardRequest | null = null; +const listeners = new Set<() => void>(); +const emit = () => listeners.forEach((l) => l()); + +export function openProfileWizard(req: WizardRequest = {}): void { request = req; emit(); } +export function closeProfileWizard(): void { request = null; emit(); } + +// One-shot hand-offs to SitesmithModal, which mounts after the wizard finishes. +let prefill: string | null = null; +let autoRegenerate = false; +let skippedThisSession = false; +export const setSitesmithPrefill = (m: string | null) => { prefill = m; }; +export const takeSitesmithPrefill = (): string | null => { const m = prefill; prefill = null; return m; }; +export const requestAutoRegenerate = () => { autoRegenerate = true; }; +export const takeAutoRegenerate = (): boolean => { const a = autoRegenerate; autoRegenerate = false; return a; }; +/** Set when the first-run wizard was skipped, so the empty-state button stops asking. */ +export const markWizardSkipped = () => { skippedThisSession = true; }; +export const wizardWasSkipped = () => skippedThisSession; +export function __resetProfileWizardForTests(): void { + request = null; prefill = null; autoRegenerate = false; skippedThisSession = false; emit(); +} + +const subscribe = (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; }; +const getRequest = () => request; + +/** The saved profile (or null). Reads the extras ref, so it is fresh on every render. */ +export function useSitesmithProfile(): SitesmithProfile | null { + const extras = useProjectExtras(); + return normalizeProfile(extras.get(PROFILE_EXTRAS_KEY, null)); +} + +/* ---------- Wizard ---------- */ + +const STEPS = 5; +const TYPE_SUGGESTIONS = ['Plumber', 'Electrician', 'Restaurant', 'Hair salon', 'Photographer', 'Law firm', 'Dentist', 'Fitness studio', 'Landscaper', 'Consultant']; + +interface WizardProps { + initial?: SitesmithProfile | null; + onDone(profile: SitesmithProfile): void; + onCancel(): void; + cancelLabel?: string; +} + +export const ProfileWizard: React.FC = ({ initial, onDone, onCancel, cancelLabel = 'Cancel' }) => { + const extras = useProjectExtras(); + const { design, updateDesign } = useSiteDesign(); + const [step, setStep] = useState(0); + const [draft, setDraft] = useState(() => ({ ...EMPTY_PROFILE, ...(initial ?? {}) })); + const [serviceText, setServiceText] = useState(''); + const [showErrors, setShowErrors] = useState(false); + // Design as it was when the wizard opened, restored if the palette preview is abandoned. + const designAtOpen = useRef(design); + const [previewed, setPreviewed] = useState(false); + + const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })); + + const addServices = (raw: string) => { + const items = raw.split(/[,\n]/).map((s) => s.trim()).filter(Boolean); + if (!items.length) return; + setDraft((d) => normalizeProfile({ ...d, services: [...d.services, ...items] }) ?? d); + setServiceText(''); + }; + + /** Draft with any half-typed service folded in. */ + const effective = (): SitesmithProfile => { + const items = serviceText.split(/[,\n]/).map((s) => s.trim()).filter(Boolean); + return normalizeProfile({ ...draft, services: [...draft.services, ...items] }) ?? draft; + }; + + const stepError = (): string | undefined => { + const { errors } = validateProfile(effective()); + if (step === 0) return errors.business_name || errors.business_type; + if (step === 1) return errors.services; + if (step === 3) return errors.tone; + return undefined; + }; + + const next = () => { + if (stepError()) { setShowErrors(true); return; } + if (step === 1) addServices(serviceText); + setShowErrors(false); + setStep((s) => Math.min(STEPS - 1, s + 1)); + }; + + const revertPalette = useCallback(() => { + if (previewed) updateDesign(designAtOpen.current); + }, [previewed, updateDesign]); + + const cancel = () => { revertPalette(); onCancel(); }; + + const finish = () => { + const profile = effective(); + if (!isProfileComplete(profile)) { setShowErrors(true); return; } + extras.set(PROFILE_EXTRAS_KEY, profile); + onDone(profile); + }; + + const err = showErrors ? stepError() : undefined; + + return ( + +
+
+ {Array.from({ length: STEPS }, (_, i) => ( + + ))} +
+ + {step === 0 && ( +
+

Tell us about your business

+ + patch({ business_name: e.target.value })} placeholder="Acme Plumbing" /> + + patch({ business_type: e.target.value })} placeholder="Plumber" /> + {TYPE_SUGGESTIONS.map((t) => +
+ )} + + {step === 1 && ( +
+

What do you offer?

+ +
+ {draft.services.map((s) => ( + + {s} + + + ))} +
+ setServiceText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addServices(serviceText); } }} + placeholder="Press Enter to add, e.g. Drain cleaning" /> +
+ )} + + {step === 2 && ( +
+

Where do you work?

+ + patch({ area: e.target.value })} placeholder="Austin and surrounding towns" /> +
+ )} + + {step === 3 && ( +
+

How should your site sound?

+
+ {TONES.map((t) => ( + + ))} +
+
+ )} + + {step === 4 && ( +
+

Pick a look

+

Click one to preview it on your site. You can change it any time.

+
+ {PALETTES.map((p) => ( + + ))} +
+
+ )} + + {err &&
{err}
} + +
+ + + {step > 0 && } + {step < STEPS - 1 + ? + : } +
+
+
+ ); +}; + +/* ---------- Host (mounted once in CanvasOverlays) ---------- */ + +/** Selected node is a top-level block of the page (a "section" in the person's eyes). */ +function isTopLevelSelection(ctx: { query: any; selectedIds: string[] }): boolean { + if (ctx.selectedIds.length !== 1) return false; + const id = ctx.selectedIds[0]; + if (!id || id === 'ROOT') return false; + return ctx.query.node(id).get().data.parent === 'ROOT'; +} + +export const ProfileWizardHost: React.FC = () => { + const req = useSyncExternalStore(subscribe, getRequest, getRequest); + const profile = useSitesmithProfile(); + const { isWHP } = useEditorConfig(); + + // Sitesmith only exists on WHP; standalone gets no commands. + useRegisterCommands(isWHP ? [ + { + id: 'site.business-profile', + title: 'Business profile…', + group: 'site', + keywords: ['sitesmith', 'business', 'profile', 'tone', 'services'], + run: () => openProfileWizard(), + }, + { + id: 'edit.regenerate-section', + title: 'Regenerate this section', + group: 'edit', + keywords: ['sitesmith', 'rewrite', 'regenerate', 'section', 'ai'], + when: isTopLevelSelection, + run: (ctx) => { + const target = buildSitesmithTarget(ctx.query, ctx.selectedIds[0]); + if (!target) return; + const go = () => { requestAutoRegenerate(); ctx.openSitesmith(target); }; + if (isProfileComplete(profile)) go(); + else openProfileWizard({ onDone: go }); + }, + }, + ] : [], [isWHP, profile]); + + if (!req) return null; + return ( + { closeProfileWizard(); req.onCancel?.(); }} + onDone={(p) => { closeProfileWizard(); req.onDone?.(p); }} + /> + ); +}; + +/** First-run path from the empty-canvas button: ask the questions, then open the chat with a starter message. */ +export function openWizardThenSitesmith(openSitesmith: () => void): void { + openProfileWizard({ + cancelLabel: 'Skip', + onDone: (p) => { setSitesmithPrefill(`Create a site for ${p.business_name}`); openSitesmith(); }, + onCancel: () => { markWizardSkipped(); openSitesmith(); }, + }); +} diff --git a/craft/src/panels/sitesmith/SitesmithModal.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.test.tsx new file mode 100644 index 0000000..44792be --- /dev/null +++ b/craft/src/panels/sitesmith/SitesmithModal.test.tsx @@ -0,0 +1,150 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { EditorConfigProvider } from '../../state/EditorConfigContext'; +import { SaveGuardProvider } from '../../state/SaveGuardContext'; +import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext'; +import { NoticeProvider, useNotices } from '../../state/NoticeContext'; +import { SitesmithProvider } from '../../state/SitesmithContext'; +import { SitesmithModal } from './SitesmithModal'; +import { buildSitesmithTarget } from '../../utils/sitesmith-target'; +import { __resetProfileWizardForTests, ProfileWizardHost } from './ProfileWizard'; +import { getCommand, __resetCommandsForTests } from '../../editor/commands'; + +const CFG = { user: 'u', apiUrl: '/api/site-builder.php', csrfToken: 't', siteId: 7, siteDomain: 'x.com', siteName: 'x', backUrl: '/', isRoot: false }; +const PROFILE = { business_name: 'Acme', business_type: 'Plumber', services: ['Drains'], area: 'Austin', tone: 'bold' }; + +const node = (type: string, props: any, extra: any = {}) => ({ + type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, + custom: {}, hidden: false, nodes: [], linkedNodes: {}, ...extra, +}); +const INITIAL = JSON.stringify({ + ROOT: node('Container', { style: {}, tag: 'div' }, { nodes: ['intro', 'sec-1'] }), + intro: node('Heading', { text: 'Untouched intro', level: 'h2' }, { parent: 'ROOT' }), + 'sec-1': node('Container', { style: {}, tag: 'section' }, { parent: 'ROOT', nodes: ['h-1'] }), + 'h-1': node('Heading', { text: 'Original heading', level: 'h2' }, { parent: 'sec-1' }), +}); + +let h: EditorHarness; +let notices: ReturnType; +let extras: ReturnType; +let sendBodies: any[]; +let sendReply: any; +const onClose = vi.fn(); + +const Spy: React.FC = () => { notices = useNotices(); extras = useProjectExtras(); return null; }; + +const flush = () => h.act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +async function mount(withTarget: boolean, profile: unknown = PROFILE, initialState: string = INITIAL) { + h = renderEditorHarness({ + initialState, + wrap: (tree) => ( + + {tree} + + ), + }); + const target = withTarget ? buildSitesmithTarget(h.query, 'sec-1') : null; + h.mountChild(<>); + if (profile) h.act(() => extras.set('sitesmithProfile', profile)); + await flush(); +} +const regenBtn = () => [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Regenerate this section')) as HTMLButtonElement | undefined; + +beforeEach(() => { + __resetProfileWizardForTests(); + __resetCommandsForTests(); + onClose.mockReset(); + sendBodies = []; + sendReply = { + ok: true, + response: { type: 'patch', message: 'done', ops: [{ + op: 'replace_node', node_id: 'sec-1', + tree: { type: { resolvedName: 'Container' }, props: { tag: 'section', style: {} }, nodes: [ + { type: { resolvedName: 'Heading' }, props: { text: 'Fresh copy for Acme', level: 'h2' }, nodes: [] }, + ] }, + }] }, + }; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => { + const json = (v: unknown) => ({ json: async () => v }); + if (url.includes('action=send')) { sendBodies.push(JSON.parse(init.body)); return json(sendReply); } + if (url.includes('action=entitlement')) return json({ ok: true, summary: { enabled: true, status: 'OK_MONTHLY', monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } }); + return json({ ok: true, messages: [] }); + })); +}); +afterEach(() => { h?.unmount(); vi.unstubAllGlobals(); }); + +describe('SitesmithModal regenerate', () => { + test('replaces the target section, sends intent + profile, and ONE undo restores it', async () => { + await mount(true); + expect(h.container.textContent).toContain('Original heading'); + h.act(() => { regenBtn()!.click(); }); + await flush(); + + expect(sendBodies).toHaveLength(1); + expect(sendBodies[0].intent).toBe('regenerate_section'); + expect(sendBodies[0].profile).toMatchObject({ business_name: 'Acme' }); + expect(sendBodies[0].target.node_id).toBe('sec-1'); + + expect(h.container.textContent).toContain('Fresh copy for Acme'); + expect(h.container.textContent).not.toContain('Original heading'); + expect(h.container.textContent).toContain('Untouched intro'); + expect(notices.notices.some((n) => n.message === 'Section regenerated - Undo to go back')).toBe(true); + expect(onClose).toHaveBeenCalled(); + + h.act(() => { h.actions.history.undo(); }); + expect(h.container.textContent).toContain('Original heading'); + expect(h.container.textContent).not.toContain('Fresh copy for Acme'); + // Section is back in its original slot (after the intro heading). + const rootKids: string[] = h.query.node('ROOT').get().data.nodes; + expect(rootKids).toHaveLength(2); + expect(rootKids[0]).toBe('intro'); + }); + + test('a failed model reply leaves the section untouched', async () => { + sendReply = { ok: true, response: { type: 'patch', message: 'x', ops: [] } }; + await mount(true); + h.act(() => { regenBtn()!.click(); }); + await flush(); + expect(h.container.textContent).toContain('Original heading'); + expect(document.querySelector('[role="alert"]')?.textContent).toMatch(/did not return/i); + expect(onClose).not.toHaveBeenCalled(); + }); + + test('the button is absent without a target', async () => { + await mount(false); + expect(regenBtn()).toBeUndefined(); + }); + + test('profile summary line shows the business', async () => { + await mount(true); + expect(document.querySelector('.sitesmith-profile-line')?.textContent).toContain('Acme'); + }); +}); + +describe('first run and commands', () => { + test('empty canvas + no profile: the chat steps aside and the wizard opens first', async () => { + await mount(false, null, ''); + expect(onClose).toHaveBeenCalled(); + expect(document.querySelector('.sitesmith-wiz')).not.toBeNull(); + }); + + test('a canvas with content is not interrupted by the wizard', async () => { + await mount(false, null); + expect(onClose).not.toHaveBeenCalled(); + expect(document.querySelector('.sitesmith-wiz')).toBeNull(); + }); + + test('edit.regenerate-section is enabled only for one selected top-level block', async () => { + await mount(false); + const cmd = getCommand('edit.regenerate-section')!; + expect(cmd).toBeDefined(); + const ctx = (ids: string[]) => ({ query: h.query, selectedIds: ids } as any); + expect(cmd.when!(ctx(['sec-1']))).toBe(true); + expect(cmd.when!(ctx(['h-1']))).toBe(false); // nested heading + expect(cmd.when!(ctx([]))).toBe(false); + expect(cmd.when!(ctx(['sec-1', 'intro']))).toBe(false); + expect(getCommand('site.business-profile')).toBeDefined(); + }); +}); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index bb6a499..dafe7e2 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -1,5 +1,12 @@ import React, { useState, useRef, useEffect } from 'react'; import { useEditor } from '@craftjs/core'; +import { useNotices } from '../../state/NoticeContext'; +import { useSitesmithModal } from '../../state/SitesmithContext'; +import { + openProfileWizard, openWizardThenSitesmith, useSitesmithProfile, + takeSitesmithPrefill, takeAutoRegenerate, wizardWasSkipped, +} from './ProfileWizard'; +import { applyRegeneratedSection, isProfileComplete, profileSummary } from '../../utils/sitesmith-profile'; import { useEditorConfig } from '../../state/EditorConfigContext'; import { useSitesmith } from '../../hooks/useSitesmith'; import { useApplyAiResponse } from '../../utils/apply-ai-response'; @@ -23,7 +30,10 @@ interface Props { export const SitesmithModal: React.FC = ({ onClose, target }) => { const cfg = useEditorConfig(); const siteId = cfg.whpConfig?.siteId ?? 0; - const { query } = useEditor(); + const { query, actions } = useEditor(); + const { notify } = useNotices(); + const { open: reopen } = useSitesmithModal(); + const profile = useSitesmithProfile(); const { summary, messages, send, loading, clearHistory } = useSitesmith(siteId); const apply = useApplyAiResponse(); const [busy, setBusy] = useState(false); @@ -38,6 +48,19 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { }; }, []); + // First-run: an empty canvas with no business profile asks the questions first, + // then comes back here with a starter message. Keep the prefill for this mount. + const [prefill] = useState(() => takeSitesmithPrefill()); + const wantsWizard = useRef( + !!cfg.whpConfig && !target && !profile && !prefill && !wizardWasSkipped() + && (query.node('ROOT').get().data.nodes ?? []).length === 0, + ); + useEffect(() => { + if (!wantsWizard.current) return; + onClose(); + openWizardThenSitesmith(() => reopen()); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY'); const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }; @@ -52,11 +75,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { setBusy(true); setError(null); try { const canvas = summarizeCanvas(query.getSerializedNodes()); - const result = await send(text, canvas, target ? { - node_id: target.nodeId, - display_name: target.displayName, - tree_json: target.treeJson, - } : undefined); + const result = await send(text, canvas, targetPayload); if (!result.ok) { setError(result.message || 'Failed'); return; } if (result.response.type === 'replace' && result.response.scope === 'site') { setPendingReplace(result.response); @@ -68,6 +87,43 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { finally { setBusy(false); } }; + const targetPayload = target + ? { node_id: target.nodeId, display_name: target.displayName, tree_json: target.treeJson } + : undefined; + + /** One click: rewrite the targeted section for this business, as a single undo step. */ + const regenerate = async () => { + if (!target || busy) return; + setBusy(true); setError(null); + try { + const canvas = summarizeCanvas(query.getSerializedNodes()); + const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section' }); + if (!result.ok) { setError(result.message || 'Failed'); return; } + const r = applyRegeneratedSection(actions, query, result.response, target.nodeId); + if (!r.ok) { setError(r.message || 'Apply failed'); return; } + notify({ + kind: 'success', + message: 'Section regenerated - Undo to go back', + actionLabel: 'Undo', + onAction: () => actions.history.undo(), + timeoutMs: 8000, + }); + onClose(); + } catch (e: any) { setError(String(e?.message ?? e)); } + finally { setBusy(false); } + }; + + const regenerateClick = () => { + if (isProfileComplete(profile)) void regenerate(); + else openProfileWizard({ onDone: () => void regenerate() }); + }; + + // Opened by the "Regenerate this section" command: run straight away. + const autoRegen = useRef(!!target && takeAutoRegenerate()); + useEffect(() => { + if (autoRegen.current && canChat) { autoRegen.current = false; void regenerate(); } + }); // eslint-disable-line react-hooks/exhaustive-deps + const confirmReplace = async () => { if (!pendingReplace) return; const r = await apply(pendingReplace); @@ -92,6 +148,8 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { if (!r.ok) setError(r.error || 'Failed to clear history'); }; + if (wantsWizard.current) return null; + return ( = ({ onClose, target }) => { Editing {target.displayName} — describe the change you want and Sitesmith will modify just this block.
)} + {cfg.whpConfig && ( +
+ {profile && isProfileComplete(profile) + ? Writing for {profileSummary(profile)} + : Tell Sitesmith about your business for better results.} + +
+ )} + {target && canChat && ( + + )} {error &&
{error}
} {loading ?
Loading…
@@ -166,6 +239,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { disabled={!canChat} placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'} onSend={handleSend} + initialValue={prefill ?? undefined} /> )} diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index a3b721f..90b6320 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -3218,3 +3218,64 @@ body { .stock-credit a, .stock-footer a { color: inherit; text-decoration: underline; } .stock-status { font-size: 11px; color: var(--color-text-dim); text-align: center; padding: 10px 0; } .stock-status.error { color: var(--color-danger); } + +/* ===== #58 Sitesmith business profile ===== */ +.sitesmith-wiz { + width: min(480px, 92vw); + background: var(--color-bg-elevated); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 20px 22px; + display: flex; + flex-direction: column; + gap: 14px; +} +.sitesmith-wiz-dots { display: flex; gap: 6px; justify-content: center; } +.sitesmith-wiz-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-border); } +.sitesmith-wiz-dot.done { background: var(--color-text-muted); } +.sitesmith-wiz-dot.active { background: var(--color-accent); } +.sitesmith-wiz-body { display: flex; flex-direction: column; gap: 8px; min-height: 170px; } +.sitesmith-wiz-title { margin: 0 0 4px; font-size: 16px; font-weight: 600; } +.sitesmith-wiz-label { font-size: 12px; color: var(--color-text-muted); margin-top: 4px; } +.sitesmith-wiz-hint { margin: 0; font-size: 12px; color: var(--color-text-muted); } +.sitesmith-wiz-input { + background: var(--color-bg-base); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 8px 10px; + font-size: 14px; +} +.sitesmith-wiz-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.sitesmith-wiz-chip { + display: inline-flex; align-items: center; gap: 4px; + background: var(--color-accent-subtle); border-radius: 999px; padding: 2px 4px 2px 10px; font-size: 12px; +} +.sitesmith-wiz-chip button { background: none; border: none; color: inherit; cursor: pointer; font-size: 14px; line-height: 1; } +.sitesmith-wiz-tones { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.sitesmith-wiz-tone { + display: flex; flex-direction: column; gap: 2px; text-align: left; cursor: pointer; + background: var(--color-bg-base); color: var(--color-text); + border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 10px; +} +.sitesmith-wiz-tone span { font-size: 12px; color: var(--color-text-muted); } +.sitesmith-wiz-tone.active { border-color: var(--color-accent); box-shadow: 0 0 0 2px var(--color-accent-subtle); } +.sitesmith-wiz-error { font-size: 12px; color: var(--color-danger); } +.sitesmith-wiz-footer { display: flex; align-items: center; gap: 8px; } +.sitesmith-wiz-btn { border-radius: var(--radius-sm); padding: 7px 14px; font-size: 13px; cursor: pointer; border: 1px solid var(--color-border); } +.sitesmith-wiz-btn.ghost { background: transparent; color: var(--color-text-muted); } +.sitesmith-wiz-btn.primary { background: var(--color-accent); border-color: var(--color-accent); color: #fff; font-weight: 600; } + +.sitesmith-profile-line { + display: flex; align-items: center; gap: 8px; font-size: 12px; color: #a1a1aa; margin-bottom: 8px; +} +.sitesmith-profile-edit, .sitesmith-profile-open { + background: transparent; border: 1px solid #3f3f46; color: inherit; border-radius: 4px; padding: 2px 8px; font-size: 12px; cursor: pointer; +} +.sitesmith-profile-open { width: 100%; padding: 7px 10px; color: var(--color-text); border-color: var(--color-border); } +.sitesmith-regenerate-btn { + align-self: flex-start; margin-bottom: 10px; background: #7c3aed; color: #fff; border: none; + border-radius: 6px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; +} +.sitesmith-regenerate-btn:disabled { opacity: 0.6; cursor: not-allowed; } diff --git a/craft/src/utils/sitesmith-profile.test.ts b/craft/src/utils/sitesmith-profile.test.ts new file mode 100644 index 0000000..a97152f --- /dev/null +++ b/craft/src/utils/sitesmith-profile.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from 'vitest'; +import { + EMPTY_PROFILE, normalizeProfile, validateProfile, isProfileComplete, profileToPrompt, profileSummary, +} from './sitesmith-profile'; + +const good: any = { business_name: 'Acme', business_type: 'Plumber', services: ['Drains', 'Leaks'], area: 'Austin', tone: 'bold' }; + +describe('sitesmith-profile', () => { + test('EMPTY_PROFILE is incomplete and names every required field', () => { + const v = validateProfile(EMPTY_PROFILE); + expect(v.ok).toBe(false); + expect(Object.keys(v.errors).sort()).toEqual(['business_name', 'business_type', 'services']); + }); + + test('complete profile validates; area and notes are optional', () => { + expect(isProfileComplete({ ...good, area: '' })).toBe(true); + expect(validateProfile(good).ok).toBe(true); + }); + + test('whitespace-only answers do not count', () => { + expect(isProfileComplete({ ...good, business_name: ' ' })).toBe(false); + expect(isProfileComplete({ ...good, services: [' ', ''] })).toBe(false); + }); + + test('normalize drops unknown keys, dedupes/trims services, falls back on a bad tone', () => { + const n = normalizeProfile({ ...good, services: [' Drains ', 'Drains', 7, ''], tone: 'loud', evil: '' })!; + expect(n.services).toEqual(['Drains']); + expect(n.tone).toBe('friendly'); + expect(n).not.toHaveProperty('evil'); + expect(n).not.toHaveProperty('notes'); + expect(normalizeProfile('nope')).toBeNull(); + expect(normalizeProfile([])).toBeNull(); + }); + + test('normalized profile stays under the 2000-char backend cap even for huge input', () => { + const big = 'x'.repeat(5000); + const n = normalizeProfile({ business_name: big, business_type: big, services: Array(50).fill(big).map((s, i) => s + i), area: big, tone: 'calm', notes: big })!; + expect(JSON.stringify(n).length).toBeLessThan(2000); + }); + + test('profileToPrompt carries name, type, services, area and tone', () => { + const line = profileToPrompt(normalizeProfile(good)!); + expect(line).toContain('Acme is a Plumber'); + expect(line).toContain('Drains, Leaks'); + expect(line).toContain('Austin'); + expect(line).toContain('Tone: bold'); + }); + + test('profileSummary skips an empty area', () => { + expect(profileSummary(normalizeProfile({ ...good, area: '' })!)).toBe('Acme · Plumber'); + }); +}); diff --git a/craft/src/utils/sitesmith-profile.ts b/craft/src/utils/sitesmith-profile.ts new file mode 100644 index 0000000..bab54ec --- /dev/null +++ b/craft/src/utils/sitesmith-profile.ts @@ -0,0 +1,147 @@ +import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith'; +import { buildNodeTree } from './craft-tree'; + +/** + * Business profile gathered by the Sitesmith onboarding wizard. Stored with the + * site (`project.json.extras.sitesmithProfile`) and sent with every Sitesmith + * request so generated copy is written for this business. + */ +export type SitesmithTone = 'friendly' | 'professional' | 'bold' | 'calm'; + +export interface SitesmithProfile { + business_name: string; + business_type: string; + services: string[]; + area: string; + tone: SitesmithTone; + notes?: string; +} + +export const PROFILE_EXTRAS_KEY = 'sitesmithProfile'; + +export const TONES: { id: SitesmithTone; label: string; hint: string }[] = [ + { id: 'friendly', label: 'Friendly', hint: 'Warm and welcoming' }, + { id: 'professional', label: 'Professional', hint: 'Clear and trustworthy' }, + { id: 'bold', label: 'Bold', hint: 'Confident and punchy' }, + { id: 'calm', label: 'Calm', hint: 'Relaxed and reassuring' }, +]; + +export const EMPTY_PROFILE: SitesmithProfile = { + business_name: '', + business_type: '', + services: [], + area: '', + tone: 'friendly', +}; + +// Per-field caps keep the whole profile well under the backend's 2000-char limit. +const MAX_NAME = 120; +const MAX_TYPE = 120; +const MAX_SERVICES = 10; +const MAX_SERVICE = 80; +const MAX_AREA = 160; +const MAX_NOTES = 400; + +const isTone = (v: unknown): v is SitesmithTone => TONES.some((t) => t.id === v); +const str = (v: unknown, max: number): string => (typeof v === 'string' ? v.trim().slice(0, max) : ''); + +/** + * Coerce anything (hydrated extras, wizard state) into a well-formed profile, + * dropping unknown keys. Returns null when `raw` is not an object. + */ +export function normalizeProfile(raw: unknown): SitesmithProfile | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const r = raw as Record; + const services = (Array.isArray(r.services) ? r.services : []) + .map((s) => str(s, MAX_SERVICE)) + .filter(Boolean) + .filter((s, i, a) => a.indexOf(s) === i) + .slice(0, MAX_SERVICES); + const out: SitesmithProfile = { + business_name: str(r.business_name, MAX_NAME), + business_type: str(r.business_type, MAX_TYPE), + services, + area: str(r.area, MAX_AREA), + tone: isTone(r.tone) ? r.tone : EMPTY_PROFILE.tone, + }; + const notes = str(r.notes, MAX_NOTES); + if (notes) out.notes = notes; + return out; +} + +export type ProfileField = 'business_name' | 'business_type' | 'services' | 'tone'; + +/** Required answers. Area and notes are optional (online-only businesses have no area). */ +export function validateProfile(p: Partial | null | undefined): { ok: boolean; errors: Partial> } { + const errors: Partial> = {}; + if (!p || !str(p.business_name, MAX_NAME)) errors.business_name = 'Tell us your business name.'; + if (!p || !str(p.business_type, MAX_TYPE)) errors.business_type = 'What kind of business is it?'; + if (!p || !Array.isArray(p.services) || !p.services.some((s) => str(s, MAX_SERVICE))) errors.services = 'Add at least one service.'; + if (!p || !isTone(p.tone)) errors.tone = 'Pick a tone.'; + return { ok: Object.keys(errors).length === 0, errors }; +} + +export function isProfileComplete(p: unknown): p is SitesmithProfile { + const n = normalizeProfile(p); + return !!n && validateProfile(n).ok; +} + +/** One short line describing the business; used as a message prefix for a backend that ignores `profile`. */ +export function profileToPrompt(p: SitesmithProfile): string { + const parts = [`${p.business_name} is a ${p.business_type}`]; + if (p.services.length) parts.push(`offering ${p.services.join(', ')}`); + if (p.area) parts.push(`serving ${p.area}`); + return `[Business: ${parts.join(' ')}. Tone: ${p.tone}.${p.notes ? ` Notes: ${p.notes}` : ''}]`; +} + +/** Compact one-liner for the Sitesmith modal header. */ +export function profileSummary(p: SitesmithProfile): string { + return [p.business_name, p.business_type, p.area].filter(Boolean).join(' · '); +} + +/** + * Replace `targetId` with the section Sitesmith returned (for `intent: + * 'regenerate_section'`), as ONE undo step. + * + * Why not `useApplyAiResponse().apply`: a replace is delete + add, which Craft + * records as two history entries. Here the delete is a normal entry and the add + * goes through `history.merge()`, which folds it into that entry. (`throttle` + * would also merge, but into whatever entry came last inside the window, which + * can be an unrelated edit made a moment earlier.) + */ +export function applyRegeneratedSection( + actions: any, + query: any, + resp: SitesmithResponse, + targetId: string, +): { ok: boolean; message?: string } { + let tree: SerializedTreeNode | undefined; + if (resp.type === 'patch') { + const op = resp.ops.find((o) => o.op === 'replace_node'); + tree = op && op.op === 'replace_node' ? op.tree : undefined; + } else if (resp.type === 'replace' && resp.scope === 'section') { + tree = resp.pages[0]?.tree; + } else if (resp.type === 'ask') { + return { ok: false, message: resp.question }; + } + if (!tree) return { ok: false, message: 'Sitesmith did not return a new section. Try again.' }; + + let nodeTree: any; + let parent: string; + let index: number; + try { + nodeTree = buildNodeTree(query, tree); + parent = query.node(targetId).get().data.parent ?? 'ROOT'; + index = (query.node(parent).childNodes() as string[]).indexOf(targetId); + } catch (e) { + return { ok: false, message: 'That section no longer exists.' }; + } + actions.delete(targetId); + try { + actions.history.merge().addNodeTree(nodeTree, parent, index); + } catch (e) { + actions.history.undo(); // never leave the page with the section deleted + return { ok: false, message: 'Could not place the new section.' }; + } + return { ok: true }; +}