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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:41:22 -07:00
1 parent 92c88496a7
commit 84bc626ed0
3 files changed
+28 -9

No files matched your search

+1
View File
@@ -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]);
@@ -30,6 +30,7 @@ let notices: ReturnType<typeof useNotices>;
let extras: ReturnType<typeof useProjectExtras>;
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();
+15 -8
View File
@@ -34,7 +34,7 @@ export const SitesmithModal: React.FC<Props> = ({ 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<SitesmithResponse | null>(null);
@@ -51,18 +51,25 @@ 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(
// 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<Props> = ({ onClose, target }) => {
if (!r.ok) setError(r.error || 'Failed to clear history');
};
if (wantsWizard.current) return null;
if (wantsWizard) return null;
return (
<Modal