diff --git a/hooks/useCatalogValuation.ts b/hooks/useCatalogValuation.ts index 3181bdf..1d74641 100644 --- a/hooks/useCatalogValuation.ts +++ b/hooks/useCatalogValuation.ts @@ -10,6 +10,9 @@ import type { import { runValuationFlow } from "@/lib/valuation/runValuationFlow"; import { captureRunLead } from "@/lib/valuation/captureRunLead"; import { linkArtistToAccount } from "@/lib/valuation/linkArtistToAccount"; +import { savePendingIntent } from "@/lib/valuation/savePendingIntent"; +import { readPendingIntent } from "@/lib/valuation/readPendingIntent"; +import { clearPendingIntent } from "@/lib/valuation/clearPendingIntent"; type Phase = "idle" | "running" | "done" | "error"; @@ -28,7 +31,10 @@ export type CatalogValuationState = { /** * Drives the catalog valuation behind the Privy sign-in gate (chat#1798). The * run trigger opens Privy when signed out and, on login, auto-fires the run for - * the **originally** selected artist. Render inside `PrivyProvider`. + * the **originally** selected artist. The deferred intent is persisted to + * sessionStorage — not just a ref — because fresh-signup auth churn can remount + * this hook's component mid-auth and a ref dies with its instance (chat#1850 + * post-auth intent drop). Render inside `PrivyProvider`. */ export function useCatalogValuation(): CatalogValuationState { const { authenticated, login, getAccessToken, user } = usePrivy(); @@ -65,20 +71,25 @@ export function useCatalogValuation(): CatalogValuationState { if (!picked) return; // Gate: signed out → open Privy and defer the run to a successful login. if (!authenticated) { - pendingRun.current = picked; + pendingRun.current = picked; // fast path when this instance survives auth + savePendingIntent(picked); // source of truth: survives remount + reload login(); return; } await doRun(picked); } - // Auto-fire the deferred run once the user signs in, for the stored artist. + // Auto-fire the deferred run once `authenticated` is true — on the sign-in + // flip when this instance survives, or on mount of a fresh instance after an + // auth-churn remount / full reload (then the intent comes from storage). useEffect(() => { - if (authenticated && pendingRun.current) { - const artist = pendingRun.current; - pendingRun.current = null; - void doRun(artist); - } + if (!authenticated) return; + const artist = pendingRun.current ?? readPendingIntent(); + pendingRun.current = null; + clearPendingIntent(); // consume-once: never re-fire on later mounts + if (!artist) return; + setPicked(artist); // a remount lost `picked`; restore it for the result UI + void doRun(artist); // eslint-disable-next-line react-hooks/exhaustive-deps }, [authenticated]); @@ -92,6 +103,7 @@ export function useCatalogValuation(): CatalogValuationState { pick: setPicked, clearPick: () => { pendingRun.current = null; // also drop a deferred signed-out run + clearPendingIntent(); // …including its persisted copy setPicked(null); }, run, diff --git a/lib/valuation/__tests__/clearPendingIntent.test.ts b/lib/valuation/__tests__/clearPendingIntent.test.ts new file mode 100644 index 0000000..18d376f --- /dev/null +++ b/lib/valuation/__tests__/clearPendingIntent.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearPendingIntent } from "../clearPendingIntent"; +import { readPendingIntent } from "../readPendingIntent"; +import { savePendingIntent } from "../savePendingIntent"; +import { fakeSessionStorage } from "./fakeSessionStorage"; + +const artist = { id: "spotify-123", name: "Del Water Gap" }; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("clearPendingIntent", () => { + it("removes a stored intent", () => { + vi.stubGlobal("sessionStorage", fakeSessionStorage()); + savePendingIntent(artist, 1_000); + clearPendingIntent(); + expect(readPendingIntent(2_000)).toBeNull(); + }); + + it("is a no-op when sessionStorage is unavailable (SSR)", () => { + expect(() => clearPendingIntent()).not.toThrow(); + }); +}); diff --git a/lib/valuation/__tests__/fakeSessionStorage.ts b/lib/valuation/__tests__/fakeSessionStorage.ts new file mode 100644 index 0000000..b2182bd --- /dev/null +++ b/lib/valuation/__tests__/fakeSessionStorage.ts @@ -0,0 +1,21 @@ +/** + * Map-backed Storage stand-in so the pending-intent helpers can be tested in + * Node (vitest runs without a DOM, so there is no real `sessionStorage`). + */ +export function fakeSessionStorage(): Storage { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => store.clear(), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} diff --git a/lib/valuation/__tests__/readPendingIntent.test.ts b/lib/valuation/__tests__/readPendingIntent.test.ts new file mode 100644 index 0000000..d1646e5 --- /dev/null +++ b/lib/valuation/__tests__/readPendingIntent.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readPendingIntent } from "../readPendingIntent"; +import { savePendingIntent } from "../savePendingIntent"; +import { + PENDING_INTENT_KEY, + PENDING_INTENT_MAX_AGE_MS, +} from "../pendingIntent"; +import { fakeSessionStorage } from "./fakeSessionStorage"; + +const artist = { + id: "spotify-123", + name: "Del Water Gap", + image: "https://i.scdn.co/x.jpg", + followers: 42, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("readPendingIntent", () => { + it("returns null when nothing is stored", () => { + vi.stubGlobal("sessionStorage", fakeSessionStorage()); + expect(readPendingIntent()).toBeNull(); + }); + + it("round-trips an artist saved by savePendingIntent", () => { + vi.stubGlobal("sessionStorage", fakeSessionStorage()); + savePendingIntent(artist, 1_000); + expect(readPendingIntent(2_000)).toEqual(artist); + }); + + it("returns the intent just inside the max age", () => { + vi.stubGlobal("sessionStorage", fakeSessionStorage()); + savePendingIntent(artist, 1_000); + expect(readPendingIntent(1_000 + PENDING_INTENT_MAX_AGE_MS)).toEqual( + artist, + ); + }); + + it("ignores intents older than the max age", () => { + vi.stubGlobal("sessionStorage", fakeSessionStorage()); + savePendingIntent(artist, 1_000); + expect(readPendingIntent(1_001 + PENDING_INTENT_MAX_AGE_MS)).toBeNull(); + }); + + it("returns null for malformed JSON", () => { + const storage = fakeSessionStorage(); + storage.setItem(PENDING_INTENT_KEY, "{not json"); + vi.stubGlobal("sessionStorage", storage); + expect(readPendingIntent()).toBeNull(); + }); + + it("returns null when the stored shape is invalid", () => { + const storage = fakeSessionStorage(); + storage.setItem( + PENDING_INTENT_KEY, + JSON.stringify({ artist: { name: "no id" }, savedAt: 1_000 }), + ); + vi.stubGlobal("sessionStorage", storage); + expect(readPendingIntent(2_000)).toBeNull(); + }); + + it("returns null when the timestamp is missing", () => { + const storage = fakeSessionStorage(); + storage.setItem(PENDING_INTENT_KEY, JSON.stringify({ artist })); + vi.stubGlobal("sessionStorage", storage); + expect(readPendingIntent(2_000)).toBeNull(); + }); + + it("returns null when sessionStorage is unavailable (SSR)", () => { + expect(readPendingIntent()).toBeNull(); + }); +}); diff --git a/lib/valuation/__tests__/savePendingIntent.test.ts b/lib/valuation/__tests__/savePendingIntent.test.ts new file mode 100644 index 0000000..459e970 --- /dev/null +++ b/lib/valuation/__tests__/savePendingIntent.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { savePendingIntent } from "../savePendingIntent"; +import { PENDING_INTENT_KEY } from "../pendingIntent"; +import { fakeSessionStorage } from "./fakeSessionStorage"; + +const artist = { id: "spotify-123", name: "Del Water Gap" }; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("savePendingIntent", () => { + it("persists the artist and timestamp under the namespaced key", () => { + const storage = fakeSessionStorage(); + vi.stubGlobal("sessionStorage", storage); + + savePendingIntent(artist, 1_000); + + const raw = storage.getItem(PENDING_INTENT_KEY); + expect(raw).not.toBeNull(); + expect(JSON.parse(raw as string)).toEqual({ artist, savedAt: 1_000 }); + }); + + it("is a no-op when sessionStorage is unavailable (SSR)", () => { + expect(() => savePendingIntent(artist)).not.toThrow(); + }); + + it("swallows storage write errors (private mode quotas)", () => { + vi.stubGlobal("sessionStorage", { + ...fakeSessionStorage(), + setItem: () => { + throw new Error("QuotaExceededError"); + }, + }); + expect(() => savePendingIntent(artist)).not.toThrow(); + }); +}); diff --git a/lib/valuation/clearPendingIntent.ts b/lib/valuation/clearPendingIntent.ts new file mode 100644 index 0000000..6f0ea37 --- /dev/null +++ b/lib/valuation/clearPendingIntent.ts @@ -0,0 +1,14 @@ +import { PENDING_INTENT_KEY } from "@/lib/valuation/pendingIntent"; + +/** + * Drop the persisted post-auth valuation intent — after a resume consumes it, + * or when the user clears their pick (matching the ref-clearing semantic). + */ +export function clearPendingIntent(): void { + try { + if (typeof sessionStorage === "undefined") return; + sessionStorage.removeItem(PENDING_INTENT_KEY); + } catch { + // Storage unavailable — nothing to clear. + } +} diff --git a/lib/valuation/pendingIntent.ts b/lib/valuation/pendingIntent.ts new file mode 100644 index 0000000..b4fe97c --- /dev/null +++ b/lib/valuation/pendingIntent.ts @@ -0,0 +1,15 @@ +import type { Artist } from "@/components/valuation/types"; + +/** + * The "run valuation after auth" intent persisted at the sign-in gate + * (chat#1850). It lives in sessionStorage — not a React ref — because fresh + * signups churn enough state to remount the valuation component mid-auth, + * and a ref dies with its component instance. Versioned key per the + * `recoupable-theme:v1` convention. + */ +export const PENDING_INTENT_KEY = "recoupable-valuation-pending-intent:v1"; + +/** Ignore intents older than this — a stale tab shouldn't auto-run a valuation. */ +export const PENDING_INTENT_MAX_AGE_MS = 15 * 60 * 1000; + +export type PendingIntent = { artist: Artist; savedAt: number }; diff --git a/lib/valuation/readPendingIntent.ts b/lib/valuation/readPendingIntent.ts new file mode 100644 index 0000000..9f0ea10 --- /dev/null +++ b/lib/valuation/readPendingIntent.ts @@ -0,0 +1,32 @@ +import type { Artist } from "@/components/valuation/types"; +import { + PENDING_INTENT_KEY, + PENDING_INTENT_MAX_AGE_MS, +} from "@/lib/valuation/pendingIntent"; + +/** + * Read the persisted post-auth valuation intent. Returns null when absent, + * malformed, stale (> max age — a stale tab must not auto-run), or when + * storage is unavailable (SSR / private mode). Read-only: the caller clears. + */ +export function readPendingIntent(now: number = Date.now()): Artist | null { + try { + if (typeof sessionStorage === "undefined") return null; + const raw = sessionStorage.getItem(PENDING_INTENT_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const { artist, savedAt } = parsed as Partial<{ + artist: Artist; + savedAt: number; + }>; + if (typeof artist?.id !== "string" || typeof artist.name !== "string") { + return null; + } + if (typeof savedAt !== "number") return null; + if (now - savedAt > PENDING_INTENT_MAX_AGE_MS) return null; + return artist; + } catch { + return null; + } +} diff --git a/lib/valuation/savePendingIntent.ts b/lib/valuation/savePendingIntent.ts new file mode 100644 index 0000000..16a782d --- /dev/null +++ b/lib/valuation/savePendingIntent.ts @@ -0,0 +1,22 @@ +import type { Artist } from "@/components/valuation/types"; +import { PENDING_INTENT_KEY } from "@/lib/valuation/pendingIntent"; + +/** + * Persist the picked artist before opening the auth modal so the run can + * resume even if the component remounts (or the page reloads) mid-auth. + * Best-effort: storage failures fall back to the in-memory ref path. + */ +export function savePendingIntent( + artist: Artist, + now: number = Date.now(), +): void { + try { + if (typeof sessionStorage === "undefined") return; + sessionStorage.setItem( + PENDING_INTENT_KEY, + JSON.stringify({ artist, savedAt: now }), + ); + } catch { + // Private-mode quota / disabled storage — the ref fast path still works. + } +}