| 1 | /** |
| 2 | * Product analytics (HeyCatch), on g1t.sh only: pageviews, clicks and |
| 3 | * route changes. Every event passes through lib/analytics-scrub.ts first, |
| 4 | * so inside the app names, text and titles never leave the browser, and |
| 5 | * recordings are never sent. A signed-in person is known by their account |
| 6 | * id alone (`identify` below). |
| 7 | * |
| 8 | * The SDK is fetched once the page has loaded and the browser is idle, so |
| 9 | * it never slows a page down; where a visitor is asked first |
| 10 | * (lib/analytics-consent.ts) it is not fetched at all until they agree. |
| 11 | * Imported by entry.client.tsx. A self-hosted g1t sends nothing. |
| 12 | */ |
| 13 | import type { analytics as Analytics } from "@heycatch/sdk"; |
| 14 | |
| 15 | import { allowed, analyticsHere, choice, consentRequired, onChoice } from "./analytics-consent"; |
| 16 | import { scrubEvent } from "./analytics-scrub"; |
| 17 | |
| 18 | let sdk: Promise<typeof Analytics> | null = null; |
| 19 | /** Who is signed in, kept for when the SDK arrives. */ |
| 20 | let signedIn: string | null = null; |
| 21 | |
| 22 | function load(): Promise<typeof Analytics> { |
| 23 | sdk ??= import("@heycatch/sdk").then(({ analytics }) => { |
| 24 | analytics.init({ |
| 25 | projectKey: "hck_pk_EsF6nrWNZ0tOM4cmDKA6tKBzdTr-GEW3", |
| 26 | install: { framework: "react", frameworkVersion: "19", agent: "claude-code" }, |
| 27 | respectDnt: true, |
| 28 | beforeSend: (event) => scrubEvent(event, window.location.pathname, window.location.origin), |
| 29 | }); |
| 30 | if (signedIn) analytics.setIdentity(signedIn); |
| 31 | return analytics; |
| 32 | }); |
| 33 | return sdk; |
| 34 | } |
| 35 | |
| 36 | /** After the page has loaded, when the browser has a moment. */ |
| 37 | function whenIdle(run: () => void) { |
| 38 | // Safari has no requestIdleCallback. |
| 39 | const idle = () => (typeof window.requestIdleCallback === "function" ? window.requestIdleCallback(run, { timeout: 4000 }) : setTimeout(run, 1500)); |
| 40 | if (document.readyState === "complete") idle(); |
| 41 | else window.addEventListener("load", idle, { once: true }); |
| 42 | } |
| 43 | |
| 44 | if (analyticsHere()) { |
| 45 | if (allowed()) whenIdle(() => void load()); |
| 46 | // A choice made on the banner, or changed from the footer. |
| 47 | onChoice(() => { |
| 48 | if (!consentRequired()) return; |
| 49 | if (choice() === "yes") void load().then((analytics) => analytics.optInCapturing()); |
| 50 | else if (sdk) void sdk.then((analytics) => analytics.optOutCapturing()); |
| 51 | }); |
| 52 | } |
| 53 | |
| 54 | /** Who is signed in, by account id only; null after signing out. */ |
| 55 | export function identify(userId: string | null, previous: string | null) { |
| 56 | signedIn = userId; |
| 57 | if (!sdk) return; |
| 58 | void sdk.then((analytics) => { |
| 59 | if (userId) analytics.setIdentity(userId); |
| 60 | else if (previous) analytics.resetIdentity(); |
| 61 | }); |
| 62 | } |