diff --git a/pages/_app.tsx b/pages/_app.tsx index 159c42cf698..c95bc73b86a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -19,6 +19,7 @@ import { ConfirmProvider } from "ui/components/shared/Confirm"; import LoadingScreen from "ui/components/shared/LoadingScreen"; import useAuthTelemetry from "ui/hooks/useAuthTelemetry"; import { bootstrapApp } from "ui/setup"; +import { captureAdAttribution } from "ui/utils/adAttribution"; import { listenForAccessToken } from "ui/utils/browser"; import { useLaunchDarkly } from "ui/utils/launchdarkly"; import { InstallRouteListener } from "ui/utils/routeListener"; @@ -82,6 +83,10 @@ const App = ({ apiKey, ...props }: AppProps & AuthProps) => { const router = useRouter(); let head: React.ReactNode; + useEffect(() => { + captureAdAttribution(); + }, []); + // HACK: Coordinates with the recording page to render its contents for // social meta tags. This can be removed once we are able to handle SSP // properly all the way to the pages. __N_SSP is a very private diff --git a/src/ui/utils/adAttribution.ts b/src/ui/utils/adAttribution.ts new file mode 100644 index 00000000000..7ffd9e278ee --- /dev/null +++ b/src/ui/utils/adAttribution.ts @@ -0,0 +1,87 @@ +// persists ad-campaign click IDs and utm params captured from the URL so +// we can fire a segment `Created Account` event with attribution after +// the user completes the auth0 round-trip. first-touch only: once set, +// the payload survives until clearAdAttribution() wipes it (normally +// right after the recordFirstLogin mutation succeeds). + +export type AdAttribution = { + li_fat_id: string | null; + twclid: string | null; + rdt_cid: string | null; + utm_source: string | null; + utm_medium: string | null; + utm_campaign: string | null; + utm_content: string | null; + utm_term: string | null; +}; + +export type AdAttributionInput = { + liClickId: string | null; + xClickId: string | null; + redditClickId: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmContent: string | null; + utmTerm: string | null; +}; + +const STORAGE_KEY = "replay_ad_attribution"; +const AD_PARAMS = ["li_fat_id", "twclid", "rdt_cid"] as const; +const UTM_PARAMS = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_content", + "utm_term", +] as const; + +export function captureAdAttribution(): void { + if (typeof window === "undefined") return; + if (localStorage.getItem(STORAGE_KEY)) return; + + const params = new URLSearchParams(window.location.search); + const hasAny = [...AD_PARAMS, ...UTM_PARAMS].some(k => params.get(k)); + if (!hasAny) return; + + const attribution: AdAttribution = { + li_fat_id: params.get("li_fat_id"), + twclid: params.get("twclid"), + rdt_cid: params.get("rdt_cid"), + utm_source: params.get("utm_source"), + utm_medium: params.get("utm_medium"), + utm_campaign: params.get("utm_campaign"), + utm_content: params.get("utm_content"), + utm_term: params.get("utm_term"), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(attribution)); +} + +export function readAdAttribution(): AdAttribution | null { + if (typeof window === "undefined") return null; + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as AdAttribution; + } catch { + return null; + } +} + +export function clearAdAttribution(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(STORAGE_KEY); +} + +export function toAdAttributionInput(a: AdAttribution): AdAttributionInput { + return { + liClickId: a.li_fat_id, + xClickId: a.twclid, + redditClickId: a.rdt_cid, + utmSource: a.utm_source, + utmMedium: a.utm_medium, + utmCampaign: a.utm_campaign, + utmContent: a.utm_content, + utmTerm: a.utm_term, + }; +} diff --git a/src/ui/utils/auth.ts b/src/ui/utils/auth.ts index 01e1e657b9f..f4ef71f3c77 100644 --- a/src/ui/utils/auth.ts +++ b/src/ui/utils/auth.ts @@ -1,3 +1,4 @@ +import { clearAdAttribution, readAdAttribution } from "./adAttribution"; import { setAccessTokenInBrowserPrefs } from "./browser"; export function getAuthHost() { @@ -9,13 +10,37 @@ export function getAuthClientId() { } export function login(returnTo = location.pathname + location.search) { - location.href = `/login?${new URLSearchParams({ origin: location.origin, returnTo })}`; + const params: Record = { + origin: location.origin, + returnTo, + }; + + // forward any captured ad attribution to the dashboard's /login handler, + // which forwards it into auth0 authorizationParams so the post-login + // action can pass it to ensureUserForAuth. + const attribution = readAdAttribution(); + if (attribution) { + if (attribution.li_fat_id) params.li_fat_id = attribution.li_fat_id; + if (attribution.twclid) params.twclid = attribution.twclid; + if (attribution.rdt_cid) params.rdt_cid = attribution.rdt_cid; + if (attribution.utm_source) params.utm_source = attribution.utm_source; + if (attribution.utm_medium) params.utm_medium = attribution.utm_medium; + if (attribution.utm_campaign) params.utm_campaign = attribution.utm_campaign; + if (attribution.utm_content) params.utm_content = attribution.utm_content; + if (attribution.utm_term) params.utm_term = attribution.utm_term; + } + + location.href = `/login?${new URLSearchParams(params)}`; } export function logout() { // clear the access token cookie document.cookie = "replay:access-token=; expires=-1; Max-Age=-99999999; path=/;"; + // drop any cached ad attribution so a later signup from a different + // user on the same browser doesn't inherit the previous user's click IDs. + clearAdAttribution(); + if (window.__IS_RECORD_REPLAY_RUNTIME__) { setAccessTokenInBrowserPrefs(null); location.replace("/login");