From 84bc626ed0361d2550b770002f6fe0cffc4b24b7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 13:37:20 -0700 Subject: [PATCH] fix(sitesmith): first-run wizard only for users who can use Sitesmith The business-profile wizard was forced on an empty canvas even when Sitesmith was locked (paid addon), capped or switched off. The decision now waits for the entitlement and skips the wizard unless the user can chat (OK_MONTHLY / OK_BONUS); a failed entitlement falls back to the modal. useSitesmith reports an ok:false entitlement as an error so it cannot hang. Co-Authored-By: Claude Opus 5.5 --- craft/src/hooks/useSitesmith.ts | 1 + .../panels/sitesmith/SitesmithModal.test.tsx | 13 ++++++++++- craft/src/panels/sitesmith/SitesmithModal.tsx | 23 ++++++++++++------- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index 6d5dc58..26c648e 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -24,6 +24,7 @@ export function useSitesmith(siteId: number) { const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=entitlement`, { credentials: 'include' }); const j = await r.json(); if (j.ok) { setSummary(j.summary); profileSupported.current = j.profile_supported === true; } + else setError(j.error || 'Sitesmith entitlement unavailable'); } catch (e: any) { setError(String(e?.message ?? e)); } }, [whpConfig]); diff --git a/craft/src/panels/sitesmith/SitesmithModal.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.test.tsx index 44792be..041a8a1 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.test.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.test.tsx @@ -30,6 +30,7 @@ let notices: ReturnType; let extras: ReturnType; let sendBodies: any[]; let sendReply: any; +let entitlementStatus: string; const onClose = vi.fn(); const Spy: React.FC = () => { notices = useNotices(); extras = useProjectExtras(); return null; }; @@ -57,6 +58,7 @@ beforeEach(() => { __resetCommandsForTests(); onClose.mockReset(); sendBodies = []; + entitlementStatus = 'OK_MONTHLY'; sendReply = { ok: true, response: { type: 'patch', message: 'done', ops: [{ @@ -69,7 +71,7 @@ beforeEach(() => { 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 } }); + if (url.includes('action=entitlement')) return json({ ok: true, summary: { enabled: true, status: entitlementStatus, monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } }); return json({ ok: true, messages: [] }); })); }); @@ -130,6 +132,15 @@ describe('first run and commands', () => { expect(document.querySelector('.sitesmith-wiz')).not.toBeNull(); }); + test('empty canvas + no profile, but Sitesmith is locked: no wizard, the modal (upgrade path) shows', async () => { + entitlementStatus = 'DISABLED'; + await mount(false, null, ''); + await flush(); + expect(document.querySelector('.sitesmith-wiz')).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + test('a canvas with content is not interrupted by the wizard', async () => { await mount(false, null); expect(onClose).not.toHaveBeenCalled(); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index dafe7e2..0e9f016 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -34,7 +34,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { const { notify } = useNotices(); const { open: reopen } = useSitesmithModal(); const profile = useSitesmithProfile(); - const { summary, messages, send, loading, clearHistory } = useSitesmith(siteId); + const { summary, messages, send, loading, clearHistory, error: entitlementError } = useSitesmith(siteId); const apply = useApplyAiResponse(); const [busy, setBusy] = useState(false); const [pendingReplace, setPendingReplace] = useState(null); @@ -51,18 +51,25 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { // First-run: an empty canvas with no business profile asks the questions first, // then comes back here with a starter message. Keep the prefill for this mount. const [prefill] = useState(() => takeSitesmithPrefill()); - const wantsWizard = useRef( + // Only for users who can actually use Sitesmith: decided once the + // entitlement arrives (a locked/capped user gets the modal's upgrade path). + const [wantsWizard, setWantsWizard] = useState(() => !!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 wizardFired = useRef(false); + useEffect(() => { + if (!wantsWizard || wizardFired.current) return; + if (entitlementError || (summary && !canChat)) { setWantsWizard(false); return; } + if (!summary) return; + wizardFired.current = true; + onClose(); + openWizardThenSitesmith(() => reopen()); + }, [wantsWizard, summary, canChat, entitlementError]); // eslint-disable-line react-hooks/exhaustive-deps + const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }; const header: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid #27272a', gap: 8 }; const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' }; @@ -148,7 +155,7 @@ export const SitesmithModal: React.FC = ({ onClose, target }) => { if (!r.ok) setError(r.error || 'Failed to clear history'); }; - if (wantsWizard.current) return null; + if (wantsWizard) return null; return (