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 (