@mrrocket/sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this package. It follows semantic versioning; before 1.0 a minor version may change an API.
4
4
 
5
+ ## 0.2.0
6
+
7
+ - **Session recordings** (rrweb 2.1.6 via `@rrweb/record`, pinned): recorded only with the visitor's own replay consent, separate from analytics and off by default: new `replay` prop and `setConsent({ replay })` (`setConsent` now also takes `{ analytics?, replay? }`; `useCockpit()` returns `replay`). The recorder is a dynamic import, loaded only when a visit records (about 26 KB gzip); pages without recording load about 1.9 KB more than 0.1.0.
8
+ - Privacy defaults: all inputs masked; password, hidden and card fields and payment iframes never recorded; `data-ck-mask` masks text, `data-ck-block` leaves elements out; no canvas, video, fonts, inlined images or network bodies; console only at error level, clipped.
9
+ - Sampling from the product's config (`replay.sampleRate`, default 0.1); other visits keep the last minute in memory and upload it on a JS error, a rage click or a `purchase…` / `checkout…` event. Chunks every 5 s or 64 KB, gzip'd, to `POST /api/sdk/replay`, `sendBeacon` on page hide, 30 minutes at most.
10
+ - **Heatmaps:** `$click`, `$rageclick` and `$scroll` events under the normal analytics consent (`heatmaps` prop, default true). Labels only for links and buttons, never typed text.
11
+ - New types `ReplayConfig`, `ConsentChoice`; `CockpitConfig.replay`.
12
+
5
13
  ## 0.1.0
6
14
 
7
15
  First public release on npm as `@mrrocket/sdk`, MIT licensed. Next.js lives under `@mrrocket/sdk/next`
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The MRRocket SDK. This release covers Next.js (App Router), imported from `@mrrocket/sdk/next`; other frameworks get their own entry in the same package (`@mrrocket/sdk/react` is next). One provider in your layout gives MRRocket page views, your events and A/B tests that the agents can run. MRRocket shows every variant of your real pages, because the SDK can render any variant on request.
4
4
 
5
- Status: 0.1.0, MIT licensed, compiled to ESM JavaScript with type declarations (no `transpilePackages`). Works with Next.js 15 and 16, React 19, on Node and on Cloudflare Workers (OpenNext). The source lives in the MRRocket repo (`prototype/web/packages/cockpit-next`).
5
+ Status: 0.2.0, MIT licensed, compiled to ESM JavaScript with type declarations (no `transpilePackages`). Works with Next.js 15 and 16, React 19, on Node and on Cloudflare Workers (OpenNext). The source lives in the MRRocket repo (`prototype/web/packages/cockpit-next`).
6
6
 
7
7
  ## Install
8
8
 
@@ -202,6 +202,36 @@ Apps with RevenueCat: set the subscriber attribute `$mrr_ref` (and `$mrr_click`)
202
202
 
203
203
  The referral cookie is a marketing cookie: where the law asks for consent (EU, UK), it needs it. Withdrawing consent (leaving `"cookies"`) deletes it.
204
204
 
205
+ ## 11. Heatmaps
206
+
207
+ Every click and the scroll depth of every page feed MRRocket › Heatmaps: `$click` (where it landed, the viewport and page size, a short CSS selector, a link's or button's label of at most 40 characters, and whether it hit anything clickable), `$rageclick` (the third quick click on one spot) and `$scroll` (the deepest point seen, sent when the page changes or hides). They go with your other events under the normal analytics consent, never contain typed text, and nothing inside `data-ck-mask` or `data-ck-block` gets a label. No replay consent needed: MRRocket shows them only as per-page aggregates.
208
+
209
+ ```tsx
210
+ <CockpitProvider heatmaps={false}>{children}</CockpitProvider> // no click or scroll events
211
+ ```
212
+
213
+ ## 12. Session recordings
214
+
215
+ Recordings of real visits (rrweb 2.1.6), for products whose recordings are on in MRRocket › Sessions. They need the **visitor's own consent**, separate from analytics and off by default:
216
+
217
+ ```tsx
218
+ <CockpitProvider replay={allowedByBanner}>{children}</CockpitProvider>
219
+
220
+ // or later, from your consent banner:
221
+ const { setConsent } = useCockpit();
222
+ setConsent({ replay: true }); // the visitor allowed "Session recordings"
223
+ setConsent({ replay: false }); // declined or withdrew: stops and drops what was not sent
224
+ ```
225
+
226
+ The recorder (about 26 KB gzip with rrweb) loads only when a visit records: product on, replay consent given, analytics not `"off"`, not a MRRocket preview. Pages without recording load nothing extra.
227
+
228
+ - **Masked by default:** every input value becomes `*`. Never recorded: password, hidden and card fields (`autocomplete="cc-…"`, names with card/cvc/cvv), Stripe and PayPal iframes. No canvas, video, fonts or inlined images, no network bodies; console lines only at error level, clipped.
229
+ - **Your markers:** `data-ck-mask` masks the text inside an element (a name, an email), `data-ck-block` leaves an element out entirely (a payment form).
230
+ - **What uploads:** the product's sample rate (default 10 %) of sessions from their start. Every other recording visit keeps about the last minute in memory and uploads it only when something happens: a JS error, a rage click (3 clicks within 1 s on one spot), or a `track()` event whose name starts with `purchase` or `checkout`.
231
+ - **How:** chunks every 5 s or 64 KB, gzip'd (`CompressionStream`), to `POST {host}/api/sdk/replay`; the last one with `sendBeacon` when the page hides. A recording ends after 30 minutes. It carries the SDK's session id and, after `identify()`, the user id, so MRRocket can delete a person's recordings.
232
+
233
+ MRRocket keeps recordings 30 days by default (at most 90), in the EU. Add the recording paragraph to your privacy policy; Sessions' "Copy for AI" prompt adds the banner choice, the wiring and the paragraph for you.
234
+
205
235
  ## Privacy and consent
206
236
 
207
237
  ```tsx
@@ -220,6 +250,8 @@ The referral cookie is a marketing cookie: where the law asks for consent (EU, U
220
250
  - **`respectDnt`** (default false): when the browser sends Do Not Track or Global Privacy Control, nothing is sent or stored, whatever the consent.
221
251
  - **Sessions** end after 30 minutes without an event: in the `ck_sid` cookie with `"cookies"`, in memory otherwise (a reload starts a new session id).
222
252
  - Previews inside MRRocket (`ck_embed=1`) track nothing.
253
+ - **Session recordings** need `replay` consent on top (see 12); `consent` alone never records.
254
+ - **Heatmap clicks and scroll depth** follow `consent` like page views (see 11).
223
255
 
224
256
  ## What it sends
225
257
 
@@ -248,4 +280,6 @@ The referral cookie is a marketing cookie: where the law asks for consent (EU, U
248
280
 
249
281
  Answers `202 { ok: true, received, dropped }`; `400` bad body, `404` unknown key, `413` too large, `429` over 600 requests a minute per key. The server adds the country (from `x-vercel-ip-country` or `cf-ipcountry`) and browser, OS and device (from the user agent); the IP only feeds the daily hash. Bot user agents are not stored. `signup`, `checkout` and `purchase` feed Web analytics, `$identify` creates a person in People. Raw events are kept 90 days.
250
282
 
251
- `GET {host}/api/sdk/config?key=…` returns the experiments for the key.
283
+ `GET {host}/api/sdk/config?key=…` returns the experiments for the key, and `replay: { sampleRate }` while the product records sessions.
284
+
285
+ `POST {host}/api/sdk/replay` takes recording chunks: `{ key, sessionId, distinctId?, identified?, start, seq, consent: { replay: true }, trigger, enc: "gzip" | "json", data, meta }`, at most 1 MB per request. `403` without the consent flag or while the product's recordings are off; `409` once a recording passed 30 minutes or 20 MB (the SDK stops).
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Clicks and scroll depth for heatmaps, and the rage-click detector session recordings share. Small on purpose: it is
3
+ * part of every page that uses the provider. Runs under the normal analytics consent; it never reads what anyone typed.
4
+ *
5
+ * A click is described by where it landed (x in CSS pixels with the viewport width, y in page pixels with the page
6
+ * height), a short CSS selector and, for links and buttons only, a label of at most 40 characters. Nothing inside
7
+ * `data-ck-mask` or `data-ck-block` gets a label, form fields never do.
8
+ */
9
+ /** Elements a click is meant for. A click on anything else is a dead click. */
10
+ export declare const INTERACTIVE = "a[href],button,input,select,textarea,label,summary,[role=\"button\"],[role=\"link\"],[role=\"tab\"],[role=\"menuitem\"],[role=\"checkbox\"],[role=\"switch\"],[onclick],[data-ck-goal]";
11
+ /** Text inside is masked in recordings and never becomes a click label. */
12
+ export declare const MASK_SELECTOR = "[data-ck-mask]";
13
+ /** Left out of recordings entirely (a placeholder of the same size) and never labelled. */
14
+ export declare const BLOCK_ATTR_SELECTOR = "[data-ck-block]";
15
+ export interface ClickInfo {
16
+ /** Pointer position: x in CSS pixels from the left of the viewport, y in page pixels from the top of the document. */
17
+ x: number;
18
+ y: number;
19
+ /** Viewport width and height, and the page height, in CSS pixels. */
20
+ vw: number;
21
+ vh: number;
22
+ docH: number;
23
+ /** A short CSS path, like `main > section.pricing > button`. Ids and classes only, never text. */
24
+ selector: string;
25
+ /** For links and buttons: what they say (aria-label, title or text), at most 40 characters; else "". */
26
+ label: string;
27
+ /** On a link, button or field; false is a dead click. */
28
+ interactive: boolean;
29
+ }
30
+ /** A selector for the element, three levels at most; stops early at an id. */
31
+ export declare const selectorOf: (el: Element) => string;
32
+ /** What a link or button says, never a field's value; "" inside masked or blocked parts. */
33
+ export declare const labelOf: (el: Element) => string;
34
+ /** The click's description, or null when it did not land on an element. */
35
+ export declare const clickInfo: (e: MouseEvent) => ClickInfo | null;
36
+ /**
37
+ * Three or more clicks within one second on the same spot (30 pixels) are a rage click. `push` answers true once per
38
+ * burst, on the third click.
39
+ */
40
+ export declare class RageDetector {
41
+ private readonly windowMs;
42
+ private readonly radius;
43
+ private readonly count;
44
+ private clicks;
45
+ private fired;
46
+ constructor(windowMs?: number, radius?: number, count?: number);
47
+ push(x: number, y: number, t: number): boolean;
48
+ }
49
+ /** How far down the page the visitor has seen, 0 to 100. */
50
+ export declare const seenDepth: () => number;
@@ -0,0 +1,91 @@
1
+ // src/capture.ts
2
+ var INTERACTIVE = 'a[href],button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="switch"],[onclick],[data-ck-goal]';
3
+ var MASK_SELECTOR = "[data-ck-mask]";
4
+ var BLOCK_ATTR_SELECTOR = "[data-ck-block]";
5
+ var generated = /\d{4,}|^[a-z]{1,3}-[a-z0-9]{5,}$|^_|__|css-|^sc-|^jsx-/i;
6
+ var part = (el) => {
7
+ const tag = el.tagName.toLowerCase();
8
+ if (el.id && !generated.test(el.id) && el.id.length <= 40) return `${tag}#${el.id}`;
9
+ const cls = typeof el.className === "string" ? el.className.split(/\s+/).filter((c) => c && !generated.test(c) && c.length <= 30 && !c.includes(":")) : [];
10
+ const base = cls.length ? `${tag}.${cls.slice(0, 2).join(".")}` : tag;
11
+ const same = el.parentElement ? [...el.parentElement.children].filter((c) => c.tagName === el.tagName) : [];
12
+ return same.length > 1 ? `${base}:nth-of-type(${same.indexOf(el) + 1})` : base;
13
+ };
14
+ var selectorOf = (el) => {
15
+ const out = [];
16
+ let cur = el;
17
+ while (cur && out.length < 3 && cur.tagName && cur.tagName !== "BODY" && cur.tagName !== "HTML") {
18
+ const p = part(cur);
19
+ out.unshift(p);
20
+ if (p.includes("#")) break;
21
+ cur = cur.parentElement;
22
+ }
23
+ return out.join(" > ").slice(0, 160);
24
+ };
25
+ var clean = (s) => (s ?? "").replace(/\s+/g, " ").trim().slice(0, 40);
26
+ var labelOf = (el) => {
27
+ if (el.closest(`${MASK_SELECTOR},${BLOCK_ATTR_SELECTOR}`)) return "";
28
+ const tag = el.tagName.toLowerCase();
29
+ if (tag === "input") {
30
+ const type = (el.getAttribute("type") ?? "text").toLowerCase();
31
+ return type === "submit" || type === "button" ? clean(el.getAttribute("aria-label") ?? el.value) : clean(el.getAttribute("aria-label"));
32
+ }
33
+ if (tag === "select" || tag === "textarea") return clean(el.getAttribute("aria-label"));
34
+ return clean(el.getAttribute("aria-label") ?? el.getAttribute("title") ?? el.innerText ?? el.textContent);
35
+ };
36
+ var clickInfo = (e) => {
37
+ const target = e.target instanceof Element ? e.target : null;
38
+ if (!target) return null;
39
+ const hit = target.closest(INTERACTIVE);
40
+ const el = hit ?? target;
41
+ const doc = document.documentElement;
42
+ return {
43
+ x: Math.round(e.clientX),
44
+ y: Math.round(e.clientY + window.scrollY),
45
+ vw: window.innerWidth,
46
+ vh: window.innerHeight,
47
+ docH: Math.max(doc.scrollHeight, document.body?.scrollHeight ?? 0),
48
+ selector: selectorOf(el),
49
+ label: hit ? labelOf(hit) : "",
50
+ interactive: !!hit
51
+ };
52
+ };
53
+ var RageDetector = class {
54
+ constructor(windowMs = 1e3, radius = 30, count = 3) {
55
+ this.windowMs = windowMs;
56
+ this.radius = radius;
57
+ this.count = count;
58
+ }
59
+ windowMs;
60
+ radius;
61
+ count;
62
+ clicks = [];
63
+ fired = false;
64
+ push(x, y, t) {
65
+ const near = this.clicks.filter((c) => t - c.t <= this.windowMs && Math.hypot(c.x - x, c.y - y) <= this.radius);
66
+ if (near.length !== this.clicks.length) this.fired = false;
67
+ this.clicks = [...near, { x, y, t }];
68
+ if (this.clicks.length >= this.count && !this.fired) {
69
+ this.fired = true;
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+ };
75
+ var seenDepth = () => {
76
+ const doc = document.documentElement;
77
+ const h = Math.max(doc.scrollHeight, document.body?.scrollHeight ?? 0);
78
+ if (!h) return 0;
79
+ return Math.min(100, Math.round((window.scrollY + window.innerHeight) / h * 100));
80
+ };
81
+ export {
82
+ BLOCK_ATTR_SELECTOR,
83
+ INTERACTIVE,
84
+ MASK_SELECTOR,
85
+ RageDetector,
86
+ clickInfo,
87
+ labelOf,
88
+ seenDepth,
89
+ selectorOf
90
+ };
91
+ //# sourceMappingURL=capture.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/capture.ts"],"sourcesContent":["/**\n * Clicks and scroll depth for heatmaps, and the rage-click detector session recordings share. Small on purpose: it is\n * part of every page that uses the provider. Runs under the normal analytics consent; it never reads what anyone typed.\n *\n * A click is described by where it landed (x in CSS pixels with the viewport width, y in page pixels with the page\n * height), a short CSS selector and, for links and buttons only, a label of at most 40 characters. Nothing inside\n * `data-ck-mask` or `data-ck-block` gets a label, form fields never do.\n */\n\n/** Elements a click is meant for. A click on anything else is a dead click. */\nexport const INTERACTIVE =\n 'a[href],button,input,select,textarea,label,summary,[role=\"button\"],[role=\"link\"],[role=\"tab\"],[role=\"menuitem\"],[role=\"checkbox\"],[role=\"switch\"],[onclick],[data-ck-goal]';\n/** Text inside is masked in recordings and never becomes a click label. */\nexport const MASK_SELECTOR = \"[data-ck-mask]\";\n/** Left out of recordings entirely (a placeholder of the same size) and never labelled. */\nexport const BLOCK_ATTR_SELECTOR = \"[data-ck-block]\";\n\nexport interface ClickInfo {\n /** Pointer position: x in CSS pixels from the left of the viewport, y in page pixels from the top of the document. */\n x: number;\n y: number;\n /** Viewport width and height, and the page height, in CSS pixels. */\n vw: number;\n vh: number;\n docH: number;\n /** A short CSS path, like `main > section.pricing > button`. Ids and classes only, never text. */\n selector: string;\n /** For links and buttons: what they say (aria-label, title or text), at most 40 characters; else \"\". */\n label: string;\n /** On a link, button or field; false is a dead click. */\n interactive: boolean;\n}\n\n/** Ids and classes that look generated (hashes, long numbers) say nothing across builds. */\nconst generated = /\\d{4,}|^[a-z]{1,3}-[a-z0-9]{5,}$|^_|__|css-|^sc-|^jsx-/i;\n\nconst part = (el: Element) => {\n const tag = el.tagName.toLowerCase();\n if (el.id && !generated.test(el.id) && el.id.length <= 40) return `${tag}#${el.id}`;\n const cls = typeof el.className === \"string\" ? el.className.split(/\\s+/).filter((c) => c && !generated.test(c) && c.length <= 30 && !c.includes(\":\")) : [];\n const base = cls.length ? `${tag}.${cls.slice(0, 2).join(\".\")}` : tag;\n // Siblings of the same kind (two plain buttons side by side) are told apart by their place.\n const same = el.parentElement ? [...el.parentElement.children].filter((c) => c.tagName === el.tagName) : [];\n return same.length > 1 ? `${base}:nth-of-type(${same.indexOf(el) + 1})` : base;\n};\n\n/** A selector for the element, three levels at most; stops early at an id. */\nexport const selectorOf = (el: Element) => {\n const out: string[] = [];\n let cur: Element | null = el;\n while (cur && out.length < 3 && cur.tagName && cur.tagName !== \"BODY\" && cur.tagName !== \"HTML\") {\n const p = part(cur);\n out.unshift(p);\n if (p.includes(\"#\")) break;\n cur = cur.parentElement;\n }\n return out.join(\" > \").slice(0, 160);\n};\n\nconst clean = (s: string | null | undefined) => (s ?? \"\").replace(/\\s+/g, \" \").trim().slice(0, 40);\n\n/** What a link or button says, never a field's value; \"\" inside masked or blocked parts. */\nexport const labelOf = (el: Element) => {\n if (el.closest(`${MASK_SELECTOR},${BLOCK_ATTR_SELECTOR}`)) return \"\";\n const tag = el.tagName.toLowerCase();\n if (tag === \"input\") {\n const type = (el.getAttribute(\"type\") ?? \"text\").toLowerCase();\n return type === \"submit\" || type === \"button\" ? clean(el.getAttribute(\"aria-label\") ?? (el as HTMLInputElement).value) : clean(el.getAttribute(\"aria-label\"));\n }\n if (tag === \"select\" || tag === \"textarea\") return clean(el.getAttribute(\"aria-label\"));\n return clean(el.getAttribute(\"aria-label\") ?? el.getAttribute(\"title\") ?? (el as HTMLElement).innerText ?? el.textContent);\n};\n\n/** The click's description, or null when it did not land on an element. */\nexport const clickInfo = (e: MouseEvent): ClickInfo | null => {\n const target = e.target instanceof Element ? e.target : null;\n if (!target) return null;\n const hit = target.closest(INTERACTIVE);\n const el = hit ?? target;\n const doc = document.documentElement;\n return {\n x: Math.round(e.clientX),\n y: Math.round(e.clientY + window.scrollY),\n vw: window.innerWidth,\n vh: window.innerHeight,\n docH: Math.max(doc.scrollHeight, document.body?.scrollHeight ?? 0),\n selector: selectorOf(el),\n label: hit ? labelOf(hit) : \"\",\n interactive: !!hit,\n };\n};\n\n/**\n * Three or more clicks within one second on the same spot (30 pixels) are a rage click. `push` answers true once per\n * burst, on the third click.\n */\nexport class RageDetector {\n private clicks: { x: number; y: number; t: number }[] = [];\n private fired = false;\n constructor(\n private readonly windowMs = 1000,\n private readonly radius = 30,\n private readonly count = 3,\n ) {}\n push(x: number, y: number, t: number) {\n const near = this.clicks.filter((c) => t - c.t <= this.windowMs && Math.hypot(c.x - x, c.y - y) <= this.radius);\n if (near.length !== this.clicks.length) this.fired = false;\n this.clicks = [...near, { x, y, t }];\n if (this.clicks.length >= this.count && !this.fired) {\n this.fired = true;\n return true;\n }\n return false;\n }\n}\n\n/** How far down the page the visitor has seen, 0 to 100. */\nexport const seenDepth = () => {\n const doc = document.documentElement;\n const h = Math.max(doc.scrollHeight, document.body?.scrollHeight ?? 0);\n if (!h) return 0;\n return Math.min(100, Math.round(((window.scrollY + window.innerHeight) / h) * 100));\n};\n"],"mappings":"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","names":[]}
package/dist/client.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type AttributionMode } from "./referral.js";
3
- import { type CockpitConfig, type ConsentMode, type VariantId } from "./core.js";
3
+ import { type CockpitConfig, type ConsentChoice, type ConsentMode, type VariantId } from "./core.js";
4
4
  export interface CockpitProviderProps {
5
5
  /** The product's public key from Cockpit › Settings › SDK. Safe in the browser. Default: `NEXT_PUBLIC_MRROCKET_KEY`. */
6
6
  apiKey?: string;
@@ -19,6 +19,17 @@ export interface CockpitProviderProps {
19
19
  consent?: ConsentMode;
20
20
  /** Send nothing when the browser asks not to be tracked (Do Not Track or Global Privacy Control). */
21
21
  respectDnt?: boolean;
22
+ /**
23
+ * The visitor's consent to session recordings, separate from analytics (default false). Recording also needs the
24
+ * product to record (Cockpit › Sessions) and analytics not "off". Without it nothing is recorded or buffered.
25
+ * Change it later with `setConsent({ replay })`.
26
+ */
27
+ replay?: boolean;
28
+ /**
29
+ * Clicks and scroll depth for Heatmaps (default true): where a click landed, a short selector and a link's or
30
+ * button's label, never typed text. Sent under the normal analytics consent as `$click`, `$rageclick` and `$scroll`.
31
+ */
32
+ heatmaps?: boolean;
22
33
  /** Ask visitors the surveys you run in Cockpit. Set false to switch them off. */
23
34
  surveys?: boolean;
24
35
  /**
@@ -38,8 +49,11 @@ export interface CockpitProviderProps {
38
49
  * `preview` forces variants for this render, for example from the `ck_preview` search param on the server,
39
50
  * so a Cockpit preview never flickers. Without it the provider reads the param itself after the first render.
40
51
  */
41
- export declare const CockpitProvider: ({ apiKey: apiKeyProp, host: hostProp, preview: forced, embedded: embeddedProp, config: bootstrapConfig, distinctId: bootstrapId, consent: consentProp, respectDnt, surveys, attribution, referralParam, children, }: CockpitProviderProps) => import("react").JSX.Element;
42
- /** `track`, `identify`, the consent mode and its setter, the visitor id and whether the experiments have loaded. */
52
+ export declare const CockpitProvider: ({ apiKey: apiKeyProp, host: hostProp, preview: forced, embedded: embeddedProp, config: bootstrapConfig, distinctId: bootstrapId, consent: consentProp, respectDnt, replay: replayProp, heatmaps, surveys, attribution, referralParam, children, }: CockpitProviderProps) => import("react").JSX.Element;
53
+ /**
54
+ * `track`, `identify`, the consent mode, the replay consent and their setter, the visitor id and whether the
55
+ * experiments have loaded. `setConsent("cookies")` sets the mode, `setConsent({ replay: true })` the replay consent.
56
+ */
43
57
  export declare const useCockpit: () => {
44
58
  track: (event: string, props?: Record<string, unknown>) => void;
45
59
  identify: (id: string, props?: Record<string, unknown>) => void;
@@ -47,7 +61,8 @@ export declare const useCockpit: () => {
47
61
  ready: boolean;
48
62
  embedded: boolean;
49
63
  consent: ConsentMode;
50
- setConsent: (mode: ConsentMode) => void;
64
+ replay: boolean;
65
+ setConsent: (choice: ConsentChoice) => void;
51
66
  };
52
67
  /** The variant this visitor sees, for example `"control"` or `"a"`. Records the exposure. */
53
68
  export declare const useVariant: (key: string) => VariantId;
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { usePathname } from "next/navigation";
6
6
  import { Ctx, useCtx } from "./context.js";
7
7
  import { Survey, forgetStoredDismissals } from "./survey.js";
8
+ import { RageDetector, clickInfo, seenDepth } from "./capture.js";
8
9
  import {
9
10
  DEFAULT_REFERRAL_DAYS,
10
11
  captureReferral,
@@ -118,6 +119,8 @@ var CockpitProvider = ({
118
119
  distinctId: bootstrapId,
119
120
  consent: consentProp = "cookieless",
120
121
  respectDnt = false,
122
+ replay: replayProp = false,
123
+ heatmaps = true,
121
124
  surveys = true,
122
125
  attribution = "consent",
123
126
  referralParam = "ref",
@@ -128,6 +131,7 @@ var CockpitProvider = ({
128
131
  const host = resolveHost(hostProp);
129
132
  const [config, setConfig] = useState(bootstrapConfig ?? null);
130
133
  const [consent, setConsentState] = useState(consentProp);
134
+ const [replayConsent, setReplayConsent] = useState(replayProp);
131
135
  const [mounted, setMounted] = useState(false);
132
136
  const [dnt, setDnt] = useState(false);
133
137
  const [visitor, setVisitor] = useState(null);
@@ -154,9 +158,11 @@ var CockpitProvider = ({
154
158
  setDnt(privacySignal());
155
159
  setMounted(true);
156
160
  }, []);
157
- const setConsent = useCallback((mode) => {
161
+ const setConsent = useCallback((choice) => {
162
+ const { analytics: mode, replay } = typeof choice === "string" ? { analytics: choice, replay: void 0 } : choice;
158
163
  if (mode === "off") queue.current.length = 0;
159
- setConsentState(mode);
164
+ if (mode) setConsentState(mode);
165
+ if (typeof replay === "boolean") setReplayConsent(replay);
160
166
  }, []);
161
167
  const lastProp = useRef(consentProp);
162
168
  useEffect(() => {
@@ -164,6 +170,12 @@ var CockpitProvider = ({
164
170
  lastProp.current = consentProp;
165
171
  setConsent(consentProp);
166
172
  }, [consentProp, setConsent]);
173
+ const lastReplayProp = useRef(replayProp);
174
+ useEffect(() => {
175
+ if (lastReplayProp.current === replayProp) return;
176
+ lastReplayProp.current = replayProp;
177
+ setConsent({ replay: replayProp });
178
+ }, [replayProp, setConsent]);
167
179
  const storedFor = useRef(null);
168
180
  useEffect(() => {
169
181
  if (!mounted) return;
@@ -230,12 +242,12 @@ var CockpitProvider = ({
230
242
  flush(true);
231
243
  };
232
244
  }, [flush]);
233
- const live = useRef({ resolved: false, tracking, distinctId, sendId, persist: false });
234
- live.current = { resolved: visitor !== null, tracking, distinctId, sendId, persist: visitor?.mode === "cookies" };
235
- const record = useCallback((event, props, ts = Date.now()) => {
245
+ const live = useRef({ resolved: false, tracking, distinctId, sendId, persist: false, identified: false });
246
+ live.current = { resolved: visitor !== null, tracking, distinctId, sendId, persist: visitor?.mode === "cookies", identified: !!identified };
247
+ const record = useCallback((event, props, ts = Date.now(), url) => {
236
248
  const now = live.current;
237
249
  if (!now.resolved) {
238
- early.current.push(() => record(event, props, ts));
250
+ early.current.push(() => record(event, props, ts, url));
239
251
  return;
240
252
  }
241
253
  if (!now.tracking) return;
@@ -246,13 +258,20 @@ var CockpitProvider = ({
246
258
  distinctId: now.sendId,
247
259
  sessionId: touchSession(now.persist),
248
260
  ts,
249
- url: window.location.href,
261
+ url: url ?? window.location.href,
250
262
  referrer: page.referrer,
251
263
  ...page.utm,
252
264
  screen: { w: window.screen.width, h: window.screen.height }
253
265
  });
254
266
  }, []);
255
- const track = useCallback((event, props) => record(event, props), [record]);
267
+ const replayer = useRef(null);
268
+ const track = useCallback(
269
+ (event, props) => {
270
+ record(event, props);
271
+ if (live.current.tracking) replayer.current?.event(event);
272
+ },
273
+ [record]
274
+ );
256
275
  const identify = useCallback(
257
276
  (id, props) => {
258
277
  const now = live.current;
@@ -261,8 +280,9 @@ var CockpitProvider = ({
261
280
  return;
262
281
  }
263
282
  const previous = now.sendId;
264
- live.current = { ...now, distinctId: id, sendId: id };
283
+ live.current = { ...now, distinctId: id, sendId: id, identified: true };
265
284
  record("$identify", { ...props, id, ...previous && previous !== id ? { previousId: previous } : {} });
285
+ replayer.current?.identify(id);
266
286
  if (now.persist) writeCookie(ID_COOKIE, id, YEAR_S);
267
287
  setIdentified(id);
268
288
  setUser({ id, ...props });
@@ -319,24 +339,99 @@ var CockpitProvider = ({
319
339
  );
320
340
  useEffect(() => {
321
341
  enterPage();
342
+ replayer.current?.page(pathname);
322
343
  }, [pathname]);
323
344
  useEffect(() => {
324
345
  if (!tracking || lastPage.current === pathname) return;
325
346
  lastPage.current = pathname;
326
347
  track("$pageview", { path: pathname });
327
348
  }, [pathname, tracking, track]);
349
+ const rage = useRef(new RageDetector());
328
350
  useEffect(() => {
329
351
  const onClick = (e) => {
330
352
  const el = e.target?.closest("[data-ck-goal]");
331
353
  if (el?.dataset.ckGoal) track(el.dataset.ckGoal, { text: el.textContent?.trim().slice(0, 80) });
354
+ if (!live.current.tracking) return;
355
+ const c = clickInfo(e);
356
+ if (!c) return;
357
+ const raged = rage.current.push(c.x, c.y, Date.now());
358
+ replayer.current?.click(c, raged);
359
+ if (!heatmaps) return;
360
+ const props = { x: c.x, y: c.y, vw: c.vw, vh: c.vh, docH: c.docH, selector: c.selector, label: c.label, dead: !c.interactive };
361
+ record("$click", props);
362
+ if (raged) record("$rageclick", props);
332
363
  };
333
364
  document.addEventListener("click", onClick);
334
365
  return () => document.removeEventListener("click", onClick);
335
- }, [track]);
366
+ }, [track, record, heatmaps]);
367
+ const depth = useRef(null);
368
+ useEffect(() => {
369
+ if (!heatmaps || !tracking || embedded) return;
370
+ const send = () => {
371
+ const d = depth.current;
372
+ if (d && d.max > 0) record("$scroll", { depth: d.max, docH: d.docH, vw: window.innerWidth, vh: window.innerHeight }, Date.now(), d.url);
373
+ depth.current = null;
374
+ };
375
+ const measure = () => {
376
+ const doc = document.documentElement;
377
+ const docH = Math.max(doc.scrollHeight, document.body?.scrollHeight ?? 0);
378
+ const max = seenDepth();
379
+ const href = window.location.href;
380
+ if (depth.current?.url !== href) depth.current = { url: href, max, docH };
381
+ else if (max > depth.current.max) depth.current = { url: href, max, docH };
382
+ };
383
+ measure();
384
+ let frame = 0;
385
+ const onScroll = () => {
386
+ if (frame) return;
387
+ frame = window.requestAnimationFrame(() => {
388
+ frame = 0;
389
+ measure();
390
+ });
391
+ };
392
+ const onHide = () => {
393
+ send();
394
+ flush(true);
395
+ };
396
+ window.addEventListener("scroll", onScroll, { passive: true });
397
+ window.addEventListener("pagehide", onHide);
398
+ return () => {
399
+ window.removeEventListener("scroll", onScroll);
400
+ window.removeEventListener("pagehide", onHide);
401
+ window.cancelAnimationFrame(frame);
402
+ send();
403
+ };
404
+ }, [heatmaps, tracking, embedded, pathname, record, flush]);
405
+ const replayConfig = config?.replay;
406
+ const recording = tracking && replayConsent && !!replayConfig && !!apiKey;
407
+ useEffect(() => {
408
+ if (!recording || !replayConfig) return;
409
+ let active = true;
410
+ let handle = null;
411
+ void (typeof window === "undefined" ? Promise.resolve(null) : import("./replay.js")).then(
412
+ (m) => m?.startReplay({
413
+ host,
414
+ apiKey,
415
+ sessionId: touchSession(live.current.persist),
416
+ distinctId: live.current.sendId,
417
+ identified: live.current.identified,
418
+ sampleRate: replayConfig.sampleRate,
419
+ warn: warnOnce
420
+ })
421
+ ).then((h) => {
422
+ if (!active) h?.stop(true);
423
+ else replayer.current = handle = h ?? null;
424
+ }).catch(() => warnOnce("Session recording could not start (the recorder did not load)."));
425
+ return () => {
426
+ active = false;
427
+ handle?.stop(true);
428
+ if (replayer.current === handle) replayer.current = null;
429
+ };
430
+ }, [recording, replayConfig, host, apiKey]);
336
431
  const texts = useMemo(() => textChanges(config, distinctId, preview), [config, distinctId, preview]);
337
432
  const value = useMemo(
338
- () => ({ apiKey, host, user, ready, distinctId, config, preview, embedded, consent, effectiveConsent, setConsent, texts, track, identify, expose }),
339
- [apiKey, host, user, ready, distinctId, config, preview, embedded, consent, effectiveConsent, setConsent, texts, track, identify, expose]
433
+ () => ({ apiKey, host, user, ready, distinctId, config, preview, embedded, consent, effectiveConsent, replayConsent, setConsent, texts, track, identify, expose }),
434
+ [apiKey, host, user, ready, distinctId, config, preview, embedded, consent, effectiveConsent, replayConsent, setConsent, texts, track, identify, expose]
340
435
  );
341
436
  return /* @__PURE__ */ jsxs(Ctx.Provider, { value, children: [
342
437
  children,
@@ -344,8 +439,8 @@ var CockpitProvider = ({
344
439
  ] });
345
440
  };
346
441
  var useCockpit = () => {
347
- const { track, identify, distinctId, ready, embedded, consent, setConsent } = useCtx();
348
- return { track, identify, distinctId, ready, embedded, consent, setConsent };
442
+ const { track, identify, distinctId, ready, embedded, consent, replayConsent, setConsent } = useCtx();
443
+ return { track, identify, distinctId, ready, embedded, consent, replay: replayConsent, setConsent };
349
444
  };
350
445
  var useVariant = (key) => {
351
446
  const { config, distinctId, preview, expose } = useCtx();