/** * First-touch attribution capture. * * THE PROBLEM. Server-side UTM extraction reads the request body and, failing that, UTM * params on the `referer` header. But `referer` is the page the form was submitted FROM, and * a visitor who lands on `/?utm_source=reddit` and then navigates before converting arrives * with a clean referer. Measured on the live DB: waitlist rows from the last 90 days have * `ip_address` on 350/350 and `utm_source` on **0/350**. The parameters are not being lost in * transit; they are gone by submit time. * * THE FIX. Capture the landing URL's attribution ONCE, on first page view, and replay it into * every submit body from then on. The server already prefers body-supplied UTMs over the * referer, so nothing changes server-side. * * FIRST touch, not last: `capture()` never overwrites an existing record, so a visitor who * lands on an ad and later arrives via a bookmark keeps the ad attribution. * * Built on `createLocalStorageAdapter` so there is no hand-rolled Web Storage access and no * module-scope `window` — `./utils` is imported by server-safe consumers, so touching * `window` at module scope would break SSR. * * SESSION-SCOPED, deliberately and without an opt-out. Three reasons: * - first-touch attribution for a single visit is the useful signal; * - a session record does not persist an identifier across visits, which keeps this out of * consent-banner territory; * - `sessionStorage` is per-tab, so `load()`-then-`save()` cannot race another tab. * `localStorage` is shared, so two tabs opening simultaneously could both observe no * record and the second write would silently replace the first — breaking the one * invariant this module exists to hold. Web Storage has no compare-and-swap, so that race * cannot be closed reliably; the option is therefore not offered rather than offered with * a caveat. If cross-session persistence is ever needed it belongs in a server-side * cookie, where it can be written atomically. * * NEVER STORES A RAW URL. See `sanitizeLandingUrl`. */ /** Attribution parameters worth carrying from the landing URL to the submit body. */ export interface FirstTouchAttribution { utm_source?: string; utm_medium?: string; utm_campaign?: string; utm_content?: string; utm_term?: string; /** Reddit Ads click id. */ rdt_cid?: string; /** * The landing PAGE — `origin + pathname` only. Never the query string or fragment. * See `sanitizeLandingUrl` for why. */ landing_url?: string; /** ISO timestamp of first capture, for debugging stale records. */ captured_at?: string; } export declare const FIRST_TOUCH_ATTRIBUTION_KEY = "of.first_touch_attribution"; /** * Reduce a URL to `origin + pathname`. * * The raw `window.location.href` must NEVER be stored or replayed. It routinely carries * things that have nothing to do with attribution and everything to do with security: OAuth * `code`/`state`, magic-link and password-reset tokens, `access_token` in the fragment, * email addresses and other PII in query params. This value is spread into every submit body, * so anything kept here would be POSTed to our API and forwarded on to HubSpot — a token in * a CRM record is a token in every integration downstream of it. * * The path alone answers the only question worth asking ("which page did they land on"), so * the query and fragment are dropped wholesale rather than filtered. An allowlist of "safe" * params would need updating every time a new auth flow adds one; dropping everything cannot * go stale. */ export declare function sanitizeLandingUrl(url: string): string | undefined; /** Parse the tracked parameters out of a URL. Returns `{}` when none are present. */ export declare function parseAttributionFromUrl(url: string): FirstTouchAttribution; /** * Record the landing URL's attribution if nothing is stored yet. * * Idempotent and first-touch-preserving: a later call with different parameters is ignored. * Safe to call on every page view, and a no-op during SSR. * * @returns what is VERIFIABLY stored after the call — a pre-existing record, the new record * read back from storage, or `{}` when nothing was captured or the write did not stick. */ export declare function captureFirstTouchAttribution(options?: { url?: string; }): FirstTouchAttribution; /** Read the stored attribution. `{}` when nothing was captured or during SSR. */ export declare function getFirstTouchAttribution(): FirstTouchAttribution; /** * Merge stored attribution into a submit body. * * Values already on the body WIN — a form that collected a real value explicitly should not * be overwritten by a stored one. Spread this into every submit payload: * * body: JSON.stringify(withFirstTouchAttribution({ email, name })) */ export declare function withFirstTouchAttribution>(body: T): T & FirstTouchAttribution; /** Clear the stored record. Exposed for tests and for a consent-withdrawal path. */ export declare function clearFirstTouchAttribution(): void; //# sourceMappingURL=first-touch-attribution.d.ts.map