Skip to content
62 linesCodeBlameRaw
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 */
13import type { analytics as Analytics } from "@heycatch/sdk";
14
15import { allowed, analyticsHere, choice, consentRequired, onChoice } from "./analytics-consent";
16import { scrubEvent } from "./analytics-scrub";
17
18let sdk: Promise<typeof Analytics> | null = null;
19/** Who is signed in, kept for when the SDK arrives. */
20let signedIn: string | null = null;
21
22function 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. */
37function 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
44if (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. */
55export 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}