Merge issue-58-fe (Refs #58)
# Conflicts: # craft/src/editor/CanvasOverlays.tsx # craft/src/styles/editor.css
This commit is contained in:
commit
edae0d6107
12 files changed
+1017
-13
No files matched your search
@@ -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 = () => (
|
||||
<HistoryHost />
|
||||
<SpacingHandles />
|
||||
<SectionLibraryModal />
|
||||
<ProfileWizardHost />
|
||||
</>
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) })}>×</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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user