@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 +8 -0
- package/README.md +36 -2
- package/dist/capture.d.ts +50 -0
- package/dist/capture.js +91 -0
- package/dist/capture.js.map +1 -0
- package/dist/client.d.ts +19 -4
- package/dist/client.js +109 -14
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +4 -2
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +25 -0
- package/dist/core.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/replay-core.d.ts +93 -0
- package/dist/replay-core.js +122 -0
- package/dist/replay-core.js.map +1 -0
- package/dist/replay.d.ts +34 -0
- package/dist/replay.js +200 -0
- package/dist/replay.js.map +1 -0
- package/package.json +14 -2
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.
|
|
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;
|
package/dist/capture.js
ADDED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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((
|
|
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
|
|
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();
|