import { useEffect, useState } from 'react'; import { resolveVoice, type VoiceAssignment, type VoicePoint } from '@wellformed/shared'; import { api } from '../api/client'; /** * The four selling points this visit gets, and the one beat we send back. * * Three things this deliberately is not. * * It is not a cookie. The assignment lives in `sessionStorage`, and the only * thing that ever leaves the browser about it is the impression id, once, on * the signup form. Nothing is written to the device that outlives the tab, * nothing identifies anyone across visits, and there is no third party — which * keeps a measurement that only needs to count pages out of consent territory * entirely. * * It is not per-render. One assignment covers the whole tab, so opening * `/pricing` from the hero does not reshuffle the points under someone mid-read, * and a reload does not invent a second impression that never had a reader. * * It is not eager. The beat below is what separates a person from a crawler, * and the fit only counts impressions that got one. Since the measure is * durable use *per impression*, an unfiltered denominator would be mostly bots * and every weight in the model would be noise around zero. */ const ASSIGNMENT_KEY = 'wf.voice'; const BEAT_KEY = 'wf.voice.seen'; /** How long a page has to be looked at before we believe someone is looking. */ const DWELL_MS = 4000; function shaped(parsed: unknown): VoiceAssignment | null { const value = parsed as Partial; if (typeof value?.id !== 'string' || !Array.isArray(value.items)) return null; return { id: value.id, items: value.items.filter((v) => typeof v === 'string') }; } function readStored(): VoiceAssignment | null { try { const raw = sessionStorage.getItem(ASSIGNMENT_KEY); if (!raw) return null; return shaped(JSON.parse(raw)); } catch { // Private browsing, a full quota, or something that is not JSON. The page // still has to render, so every one of those is simply "no assignment". return null; } } /** * The assignment a server-rendered page carried along, as a JSON script tag. * * The server already drew and recorded the impression when it rendered the * page, so fetching another here would double-count the visit and swap four * blocks of copy under the reader. Adopting the embedded one keeps the page * and the measurement telling the same story. Stored on adoption, so the rest * of the tab stays as sticky as a fetched assignment would be. */ function readEmbedded(): VoiceAssignment | null { try { const raw = document.getElementById('wf-voice')?.textContent; if (!raw) return null; const parsed = shaped(JSON.parse(raw)); if (parsed) store(ASSIGNMENT_KEY, JSON.stringify(parsed)); return parsed; } catch { return null; } } function store(key: string, value: string): void { try { sessionStorage.setItem(key, value); } catch { // Storage being unavailable costs stickiness, not the page. } } export function useVoice(ssr?: VoiceAssignment | null): VoicePoint[] { // Order matters: an assignment this tab already had wins (stickiness), then // the one a server-rendered page embedded, then — only during server-side // rendering itself, where neither storage nor document exists — the one the // caller was handed directly. const [assignment, setAssignment] = useState( () => readStored() ?? readEmbedded() ?? ssr ?? null, ); useEffect(() => { if (assignment) return; let live = true; void api .voice() .then((next) => { if (!live) return; store(ASSIGNMENT_KEY, JSON.stringify(next)); setAssignment(next); }) .catch(() => { // The server not answering must not blank the landing page. Leaving the // assignment null renders the deterministic fallback in `resolveVoice`. }); return () => { live = false; }; }, [assignment]); useEffect(() => { const id = assignment?.id; // An empty id is the server saying "composed but not recorded" — over the // rate limit. There is nothing to beat against. if (!id) return; if (sessionStorage.getItem(BEAT_KEY) === id) return; let done = false; const beat = () => { if (done) return; done = true; cleanup(); store(BEAT_KEY, id); void api.voiceSeen(id).catch(() => { // Nothing to tell the reader, and nothing to retry: an impression that // failed to register simply does not vote. }); }; // Either signal will do. A quick scroll is as good a sign of a person as // four seconds of stillness, and waiting for both would lose the visitor // who reads the hero and leaves. const events = ['scroll', 'pointerdown', 'keydown'] as const; for (const event of events) window.addEventListener(event, beat, { once: true, passive: true }); const timer = window.setTimeout(beat, DWELL_MS); function cleanup() { window.clearTimeout(timer); for (const event of events) window.removeEventListener(event, beat); } return cleanup; }, [assignment?.id]); return resolveVoice(assignment?.items ?? []); } /** The impression to credit a signup to, if this tab has one. */ export function currentVoiceId(): string | undefined { return readStored()?.id || undefined; }