feat(site-builder): Sitesmith business profile wizard and one-click section regenerate

5-step profile wizard (name/type, services, area, tone, palette preview) saved to
extras.sitesmithProfile and sent with every Sitesmith request. Regenerate this
section rewrites the selected block for the business as a single undo step.

Refs #58

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 11:55:33 -07:00
1 parent 8860349c9b
commit 220f560972
12 files changed
+1017 -13

No files matched your search

+2
View File
@@ -3,6 +3,7 @@ import { NoticeHost } from './NoticeHost';
import { EmptyContainerAdd } from './EmptyContainerAdd';
import { DropIndicator } from './DropIndicator';
import { RichTextToolbar } from './RichTextToolbar';
import { ProfileWizardHost } from '../panels/sitesmith/ProfileWizard';
/**
* Single mount point for editor-wide overlays rendered next to the shell.
@@ -15,5 +16,6 @@ export const CanvasOverlays: React.FC = () => (
<EmptyContainerAdd />
<DropIndicator />
<RichTextToolbar />
<ProfileWizardHost />
</>
);
+81
View File
@@ -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<typeof useSitesmith>;
let extras: ReturnType<typeof useProjectExtras>;
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);
});
});
+18 -4
View File
@@ -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<SitesmithSummary | null>(null);
const [messages, setMessages] = useState<SitesmithMessage[]>([]);
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<SendResult> => {
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<string, unknown> = { 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<unknown>(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<string, unknown> = { 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' };
@@ -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' && (
<>
<PalettePicker />
{whpConfig && (
<div className="guided-section">
<button type="button" className="sitesmith-profile-open" onClick={() => openProfileWizard()}>
<i className="fa fa-briefcase" aria-hidden /> Business profile…
</button>
</div>
)}
<div className="guided-section">
<label className="guided-section-label">Favicon</label>
<AssetPicker
+3 -3
View File
@@ -1,9 +1,9 @@
import React, { useState, KeyboardEvent } from 'react';
interface Props { disabled?: boolean; placeholder?: string; onSend: (text: string) => 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<Props> = ({ disabled, placeholder, onSend }) => {
const [v, setV] = useState('');
export const ChatInput: React.FC<Props> = ({ 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<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fire(); } };
return (
@@ -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<typeof useProjectExtras>; 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(
<SaveGuardProvider><ProjectExtrasProvider><SiteDesignProvider>
<Probe />
<ProfileWizard initial={initial} onDone={onDone} onCancel={onCancel} />
</SiteDesignProvider></ProjectExtrasProvider></SaveGuardProvider>,
);
});
}
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<any>('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<any>('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);
});
});
@@ -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<unknown>(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<WizardProps> = ({ initial, onDone, onCancel, cancelLabel = 'Cancel' }) => {
const extras = useProjectExtras();
const { design, updateDesign } = useSiteDesign();
const [step, setStep] = useState(0);
const [draft, setDraft] = useState<SitesmithProfile>(() => ({ ...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<SitesmithProfile>) => 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 (
<Modal
open
onClose={cancel}
closeOnBackdropClick={false}
backdropStyle={{ zIndex: 'calc(var(--z-modal) + 10)' as unknown as number }}
backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Business profile' }}
>
<div className="sitesmith-wiz">
<div className="sitesmith-wiz-dots" role="progressbar" aria-valuemin={1} aria-valuemax={STEPS} aria-valuenow={step + 1} aria-label={`Step ${step + 1} of ${STEPS}`}>
{Array.from({ length: STEPS }, (_, i) => (
<span key={i} className={`sitesmith-wiz-dot${i === step ? ' active' : ''}${i < step ? ' done' : ''}`} />
))}
</div>
{step === 0 && (
<div className="sitesmith-wiz-body">
<h2 className="sitesmith-wiz-title">Tell us about your business</h2>
<label className="sitesmith-wiz-label" htmlFor="sw-name">Business name</label>
<input id="sw-name" className="sitesmith-wiz-input" value={draft.business_name} maxLength={120} autoFocus
onChange={(e) => patch({ business_name: e.target.value })} placeholder="Acme Plumbing" />
<label className="sitesmith-wiz-label" htmlFor="sw-type">What kind of business is it?</label>
<input id="sw-type" className="sitesmith-wiz-input" value={draft.business_type} maxLength={120} list="sw-type-list"
onChange={(e) => patch({ business_type: e.target.value })} placeholder="Plumber" />
<datalist id="sw-type-list">{TYPE_SUGGESTIONS.map((t) => <option key={t} value={t} />)}</datalist>
</div>
)}
{step === 1 && (
<div className="sitesmith-wiz-body">
<h2 className="sitesmith-wiz-title">What do you offer?</h2>
<label className="sitesmith-wiz-label" htmlFor="sw-service">Add your main services, one at a time</label>
<div className="sitesmith-wiz-chips">
{draft.services.map((s) => (
<span key={s} className="sitesmith-wiz-chip">
{s}
<button type="button" aria-label={`Remove ${s}`} onClick={() => patch({ services: draft.services.filter((x) => x !== s) })}>&times;</button>
</span>
))}
</div>
<input id="sw-service" className="sitesmith-wiz-input" value={serviceText} maxLength={80} autoFocus
onChange={(e) => 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" />
</div>
)}
{step === 2 && (
<div className="sitesmith-wiz-body">
<h2 className="sitesmith-wiz-title">Where do you work?</h2>
<label className="sitesmith-wiz-label" htmlFor="sw-area">Your service area (optional)</label>
<input id="sw-area" className="sitesmith-wiz-input" value={draft.area} maxLength={160} autoFocus
onChange={(e) => patch({ area: e.target.value })} placeholder="Austin and surrounding towns" />
</div>
)}
{step === 3 && (
<div className="sitesmith-wiz-body">
<h2 className="sitesmith-wiz-title">How should your site sound?</h2>
<div className="sitesmith-wiz-tones" role="radiogroup" aria-label="Tone">
{TONES.map((t) => (
<button key={t.id} type="button" role="radio" aria-checked={draft.tone === t.id}
className={`sitesmith-wiz-tone${draft.tone === t.id ? ' active' : ''}`}
onClick={() => patch({ tone: t.id })}>
<strong>{t.label}</strong>
<span>{t.hint}</span>
</button>
))}
</div>
</div>
)}
{step === 4 && (
<div className="sitesmith-wiz-body">
<h2 className="sitesmith-wiz-title">Pick a look</h2>
<p className="sitesmith-wiz-hint">Click one to preview it on your site. You can change it any time.</p>
<div className="palette-grid">
{PALETTES.map((p) => (
<button key={p.id} type="button"
className={`palette-preview${previewed && paletteMatches(p, design) ? ' active' : ''}`}
aria-pressed={previewed && paletteMatches(p, design)} title={p.name}
onClick={() => { updateDesign(paletteUpdates(p)); setPreviewed(true); }}>
<span className="palette-dots">
{[p.design.primaryColor, p.design.secondaryColor, p.design.accentColor, p.design.backgroundColor].map((c, i) => (
<span key={i} className="palette-dot" style={{ background: c }} />
))}
</span>
<span className="palette-name">{p.name}</span>
</button>
))}
</div>
</div>
)}
{err && <div role="alert" className="sitesmith-wiz-error">{err}</div>}
<div className="sitesmith-wiz-footer">
<button type="button" className="sitesmith-wiz-btn ghost" onClick={cancel}>{cancelLabel}</button>
<span style={{ flex: 1 }} />
{step > 0 && <button type="button" className="sitesmith-wiz-btn ghost" onClick={() => setStep(step - 1)}>Back</button>}
{step < STEPS - 1
? <button type="button" className="sitesmith-wiz-btn primary" onClick={next}>Next</button>
: <button type="button" className="sitesmith-wiz-btn primary" onClick={finish}>{previewed ? 'Keep this look' : 'Finish'}</button>}
</div>
</div>
</Modal>
);
};
/* ---------- 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 (
<ProfileWizard
initial={profile}
cancelLabel={req.cancelLabel}
onCancel={() => { 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(); },
});
}
@@ -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<typeof useNotices>;
let extras: ReturnType<typeof useProjectExtras>;
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) => (
<EditorConfigProvider config={CFG as any}>
<SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SitesmithProvider>{tree}</SitesmithProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider>
</EditorConfigProvider>
),
});
const target = withTarget ? buildSitesmithTarget(h.query, 'sec-1') : null;
h.mountChild(<><Spy /><ProfileWizardHost /><SitesmithModal onClose={onClose} target={target} /></>);
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();
});
});
+80 -6
View File
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ onClose, target }) => {
if (!r.ok) setError(r.error || 'Failed to clear history');
};
if (wantsWizard.current) return null;
return (
<Modal
open
@@ -153,6 +211,21 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
<span>Editing <strong style={{ color: '#fff' }}>{target.displayName}</strong> — describe the change you want and Sitesmith will modify just this block.</span>
</div>
)}
{cfg.whpConfig && (
<div className="sitesmith-profile-line">
{profile && isProfileComplete(profile)
? <span>Writing for <strong>{profileSummary(profile)}</strong></span>
: <span>Tell Sitesmith about your business for better results.</span>}
<button type="button" className="sitesmith-profile-edit" onClick={() => openProfileWizard()}>
{profile ? 'Edit' : 'Add'}
</button>
</div>
)}
{target && canChat && (
<button type="button" className="sitesmith-regenerate-btn" disabled={busy} onClick={regenerateClick}>
<i className="fa fa-refresh" aria-hidden /> Regenerate this section
</button>
)}
{error && <div role="alert" style={errBox}>{error}</div>}
{loading
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
@@ -166,6 +239,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
initialValue={prefill ?? undefined}
/>
)}
</div>
+61
View File
@@ -2557,3 +2557,64 @@ body {
}
.palette-dot:first-child { margin-left: 0; }
/* ===== #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; }
+52
View File
@@ -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: '<x>' })!;
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');
});
});
+147
View File
@@ -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<string, unknown>;
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<SitesmithProfile> | null | undefined): { ok: boolean; errors: Partial<Record<ProfileField, string>> } {
const errors: Partial<Record<ProfileField, string>> = {};
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 };
}