// S4e-2, Task 11 - bridges a campaign CTA click across the OTP login into the booking flow. // // sessionStorage, NEVER localStorage (Global Constraint): this is per-tab, cleared on tab close, // which is exactly what "an appointment scheduled DURING THAT LOGIN" (design doc §6.2) means // operationally. localStorage would survive into a future, unrelated session and silently inflate // `booked`. Mirrors src/utils/onboardingReturn.ts's identical sessionStorage bridge pattern. // // No expiry beyond the tab's own lifetime, by design (§6.2 - no time window): attribution lasts // exactly as long as the session the CTA link opened, however long that session runs. Not cleared // after a single booking by THIS module - Task 12 (booking) owns consume-once, and calls // clearCampaignCtaAttribution() below once the held token has attributed one booking, so a second, // unrelated booking later in the same session is not also counted. // // Shared-device correctness (see also: logout wiring below). A captured token must not outlive the // login it arrived for. Two of the three ways that could happen are closed here: // - tab closes sessionStorage dies with the tab (browser-native). // - the captured login logs out AuthService.tsx's logout() calls clearCampaignCtaAttribution() // (this module has no view into auth state, so that wiring lives // in AuthService.tsx, not here). // The third - someone clicks the CTA, never authenticates, walks away, and a DIFFERENT person then // logs in on the same still-open tab - is a residual gap: the token is architecturally patient-less // (Global Constraint: "stores NO patient"), so nothing on the client can tell that the next login is // not the one the click was for. See task-11-report.md for the full writeup; flagged to Spencer rather // than silently closed. const CAMPAIGN_CTA_SESSION_KEY = 'akashere_campaign_cta_token'; export const setCampaignCtaAttribution = (token: string): void => { try { window.sessionStorage.setItem(CAMPAIGN_CTA_SESSION_KEY, token); } catch { // sessionStorage unavailable (private mode / disabled) — non-fatal; booking still works, // the funnel just cannot attribute it. } }; export const getCampaignCtaAttribution = (): string | null => { try { return window.sessionStorage.getItem(CAMPAIGN_CTA_SESSION_KEY); } catch { return null; } }; // The consume-once seam: Task 12 calls this once the held token has been sent with a booking request, // and AuthService.tsx's logout() calls it so a captured-but-unused token never survives a sign-out. export const clearCampaignCtaAttribution = (): void => { try { window.sessionStorage.removeItem(CAMPAIGN_CTA_SESSION_KEY); } catch { // ignore } }; const CTA_PARAM = 'cta'; // Pure, dependency-free href parsing - mirrors utils/proofingFlow.ts's detect/strip split for the // identity-proofing return marker, which solves the same structural problem: this app is a // HashRouter SPA, so the query string the server appends (`?cta=`) lands INSIDE the hash // fragment (`.../#/login?cta=abc123`), not in `window.location.search` - a plain `URLSearchParams` // against `location.search` would never see it. Kept dependency-free (no react-router import) so it // is directly unit-testable without a Router harness, which is the whole reason App.tsx's // LoginRedirect calls these rather than the `useSearchParams` a naive first pass would reach for. const parseHashQuery = (href: string): { before: string; path: string; params: URLSearchParams } | null => { const hashIndex = (href ?? '').indexOf('#'); if (hashIndex === -1) return null; const before = href.slice(0, hashIndex + 1); // includes the '#' itself const afterHash = href.slice(hashIndex + 1); const queryIndex = afterHash.indexOf('?'); if (queryIndex === -1) return { before, path: afterHash, params: new URLSearchParams() }; return { before, path: afterHash.slice(0, queryIndex), params: new URLSearchParams(afterHash.slice(queryIndex + 1)), }; }; // Read the `cta` token off a HashRouter href without mutating anything. Returns null when absent or // empty - an empty `?cta=` is treated the same as no token at all. export const extractCampaignCtaToken = (href: string): string | null => { try { const parsed = parseHashQuery(href); if (!parsed) return null; const value = parsed.params.get(CTA_PARAM); return value ? value : null; } catch { return null; } }; // Strip `cta=...` from a HashRouter href, preserving any other query params and their order. Used to // scrub the address bar and browser history immediately after the token is captured - the token // must not sit visibly in the URL (copy-paste, screen share, a shoulder-surfed history entry) a // moment longer than it takes to read it into sessionStorage. (NOT a Referer-header concern: the // token lives in the fragment, after `#`, and fragments are never sent in an outbound Referer by // spec - the strip is about the address bar and history, not network exposure.) export const stripCampaignCtaParam = (href: string): string => { try { const parsed = parseHashQuery(href); if (!parsed) return href; if (!parsed.params.has(CTA_PARAM)) return href; parsed.params.delete(CTA_PARAM); const rest = parsed.params.toString(); return `${parsed.before}${parsed.path}${rest ? `?${rest}` : ''}`; } catch { return href; } }; // Fix round 1 - the orchestration that used to sit inline in App.tsx's LoginRedirect effect: extract // early-return-if-absent stash strip the URL. Pulled out so the call order, the early return, // and the try/catch around `replaceState` are all covered by a direct unit test instead of resting on // a manual browser pass - App.tsx exports nothing but the default `App`, so this was the only // remaining untested app logic in the capture path (the parsing/storage primitives above already // were). `replaceState` is injectable (defaults to the real `window.history.replaceState`) purely so // the test doesn't need jsdom navigation plumbing to assert call order and the throw-must-not-propagate // behavior. export const captureCampaignCtaAttribution = ( href: string, replaceState: (url: string) => void = (url) => window.history.replaceState(null, '', url), ): void => { const token = extractCampaignCtaToken(href); if (!token) return; setCampaignCtaAttribution(token); try { replaceState(stripCampaignCtaParam(href)); } catch { // replaceState can throw in rare embedded contexts — non-fatal, the token is already // captured; only the address bar keeps the stale query string. } };