@mrrocket/sdk 0.1.0 → 0.3.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 +15 -0
- package/README.md +36 -2
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +3 -1
- package/dist/browser.js.map +1 -1
- 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 +39 -4
- package/dist/client.js +149 -18
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +6 -2
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +92 -0
- package/dist/core.js +59 -0
- package/dist/core.js.map +1 -1
- package/dist/errors.d.ts +35 -0
- package/dist/errors.js +83 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -0
- 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/dist/server.d.ts +13 -1
- package/dist/server.js +8 -1
- package/dist/server.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/styles.js +2 -2
- package/dist/styles.js.map +1 -1
- package/package.json +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
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.3.0
|
|
6
|
+
|
|
7
|
+
- **Error tracking** for MRRocket › Build › Errors, on by default (`errors` prop): uncaught errors and unhandled promise rejections, with the page URL and the last 20 pages and clicks, go to `POST /api/sdk/errors` in batches every 2 s (`sendBeacon` on page hide), under the normal analytics consent and never from a preview. The same error once per page load, at most 20 per page load. `useCockpit().captureError(error)` reports one you caught; the `release` prop names your release. Independent of session recordings.
|
|
8
|
+
- **Feature flags** from MRRocket › Build › Feature flags: `useFlag(key)` and `useFlagVariant(key)` in the browser, `getFlag` and `getFlagVariant` on the server (`/next/server`). The config carries the Production flags; each is evaluated in the SDK from the person's `identify()` properties ("Email", "Plan" …, `@domain` matches an address, cohorts never match in the browser) and a stable bucket per flag and person. New exports `evaluateFlag`, `SDK_VERSION`, types `FlagConfig`, `FlagCondition`, `FlagConditionSet`, `FlagPerson`, `ErrorReport`.
|
|
9
|
+
- Event batches carry `sdk: { v, errors }`, so MRRocket can say which SDK a site runs and whether it reports errors.
|
|
10
|
+
- Fix: unselected stars of a 1–5 survey question are gray again (a button reset made them take the text color).
|
|
11
|
+
|
|
12
|
+
## 0.2.0
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- **Heatmaps:** `$click`, `$rageclick` and `$scroll` events under the normal analytics consent (`heatmaps` prop, default true). Labels only for links and buttons, never typed text.
|
|
18
|
+
- New types `ReplayConfig`, `ConsentChoice`; `CockpitConfig.replay`.
|
|
19
|
+
|
|
5
20
|
## 0.1.0
|
|
6
21
|
|
|
7
22
|
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).
|
package/dist/browser.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* that the build keeps one client chunk: server components import these as client references, while the plain
|
|
4
4
|
* helpers (`core`, `server`, `proxy`) stay free of the directive.
|
|
5
5
|
*/
|
|
6
|
-
export { CockpitProvider, Experiment, Text, useCockpit, useVariant, type CockpitProviderProps } from "./client.js";
|
|
6
|
+
export { CockpitProvider, Experiment, Text, useCockpit, useFlag, useFlagVariant, useVariant, type CockpitProviderProps } from "./client.js";
|
|
7
7
|
export { FeedbackBoard, FeedbackWidget, useFeedback, type FeedbackBoardProps, type FeedbackWidgetProps } from "./feedback.js";
|
|
8
8
|
export { Survey } from "./survey.js";
|
|
9
9
|
export type { CockpitUser } from "./context.js";
|
package/dist/browser.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
// src/browser.ts
|
|
4
|
-
import { CockpitProvider, Experiment, Text, useCockpit, useVariant } from "./client.js";
|
|
4
|
+
import { CockpitProvider, Experiment, Text, useCockpit, useFlag, useFlagVariant, useVariant } from "./client.js";
|
|
5
5
|
import { FeedbackBoard, FeedbackWidget, useFeedback } from "./feedback.js";
|
|
6
6
|
import { Survey } from "./survey.js";
|
|
7
7
|
import {
|
|
@@ -19,6 +19,8 @@ export {
|
|
|
19
19
|
createApi,
|
|
20
20
|
useCockpit,
|
|
21
21
|
useFeedback,
|
|
22
|
+
useFlag,
|
|
23
|
+
useFlagVariant,
|
|
22
24
|
useStyles,
|
|
23
25
|
useVariant
|
|
24
26
|
};
|
package/dist/browser.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/browser.ts"],"sourcesContent":["\"use client\";\n\n/**\n * Everything of the SDK that needs the browser or React state. It is its own file with the \"use client\" directive so\n * that the build keeps one client chunk: server components import these as client references, while the plain\n * helpers (`core`, `server`, `proxy`) stay free of the directive.\n */\nexport { CockpitProvider, Experiment, Text, useCockpit, useVariant, type CockpitProviderProps } from \"./client\";\nexport { FeedbackBoard, FeedbackWidget, useFeedback, type FeedbackBoardProps, type FeedbackWidgetProps } from \"./feedback\";\nexport { Survey } from \"./survey\";\nexport type { CockpitUser } from \"./context\";\nexport {\n type ChangelogEntry,\n type FeedbackBoardInfo,\n type FeedbackComment,\n type FeedbackList,\n type FeedbackPost,\n type FeedbackRoadmap,\n type PostStatus,\n type SurveyAnswer,\n type SurveyDefinition,\n type SurveyQuestion,\n type SurveyQuestionKind,\n createApi,\n} from \"./api\";\nexport { CSS as WIDGET_CSS, useStyles } from \"./styles\";\n"],"mappings":";;;AAOA,SAAS,iBAAiB,YAAY,MAAM,YAAY,kBAA6C;
|
|
1
|
+
{"version":3,"sources":["../src/browser.ts"],"sourcesContent":["\"use client\";\n\n/**\n * Everything of the SDK that needs the browser or React state. It is its own file with the \"use client\" directive so\n * that the build keeps one client chunk: server components import these as client references, while the plain\n * helpers (`core`, `server`, `proxy`) stay free of the directive.\n */\nexport { CockpitProvider, Experiment, Text, useCockpit, useFlag, useFlagVariant, useVariant, type CockpitProviderProps } from \"./client\";\nexport { FeedbackBoard, FeedbackWidget, useFeedback, type FeedbackBoardProps, type FeedbackWidgetProps } from \"./feedback\";\nexport { Survey } from \"./survey\";\nexport type { CockpitUser } from \"./context\";\nexport {\n type ChangelogEntry,\n type FeedbackBoardInfo,\n type FeedbackComment,\n type FeedbackList,\n type FeedbackPost,\n type FeedbackRoadmap,\n type PostStatus,\n type SurveyAnswer,\n type SurveyDefinition,\n type SurveyQuestion,\n type SurveyQuestionKind,\n createApi,\n} from \"./api\";\nexport { CSS as WIDGET_CSS, useStyles } from \"./styles\";\n"],"mappings":";;;AAOA,SAAS,iBAAiB,YAAY,MAAM,YAAY,SAAS,gBAAgB,kBAA6C;AAC9H,SAAS,eAAe,gBAAgB,mBAAsE;AAC9G,SAAS,cAAc;AAEvB;AAAA,EAYI;AAAA,OACG;AACP,SAAgB,KAAY,iBAAiB;","names":[]}
|
|
@@ -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":";AAUO,IAAM,cACT;AAEG,IAAM,gBAAgB;AAEtB,IAAM,sBAAsB;AAmBnC,IAAM,YAAY;AAElB,IAAM,OAAO,CAAC,OAAgB;AAC1B,QAAM,MAAM,GAAG,QAAQ,YAAY;AACnC,MAAI,GAAG,MAAM,CAAC,UAAU,KAAK,GAAG,EAAE,KAAK,GAAG,GAAG,UAAU,GAAI,QAAO,GAAG,GAAG,IAAI,GAAG,EAAE;AACjF,QAAM,MAAM,OAAO,GAAG,cAAc,WAAW,GAAG,UAAU,MAAM,KAAK,EAAE,OAAO,CAAC,MAAM,KAAK,CAAC,UAAU,KAAK,CAAC,KAAK,EAAE,UAAU,MAAM,CAAC,EAAE,SAAS,GAAG,CAAC,IAAI,CAAC;AACzJ,QAAM,OAAO,IAAI,SAAS,GAAG,GAAG,IAAI,IAAI,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,KAAK;AAElE,QAAM,OAAO,GAAG,gBAAgB,CAAC,GAAG,GAAG,cAAc,QAAQ,EAAE,OAAO,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,IAAI,CAAC;AAC1G,SAAO,KAAK,SAAS,IAAI,GAAG,IAAI,gBAAgB,KAAK,QAAQ,EAAE,IAAI,CAAC,MAAM;AAC9E;AAGO,IAAM,aAAa,CAAC,OAAgB;AACvC,QAAM,MAAgB,CAAC;AACvB,MAAI,MAAsB;AAC1B,SAAO,OAAO,IAAI,SAAS,KAAK,IAAI,WAAW,IAAI,YAAY,UAAU,IAAI,YAAY,QAAQ;AAC7F,UAAM,IAAI,KAAK,GAAG;AAClB,QAAI,QAAQ,CAAC;AACb,QAAI,EAAE,SAAS,GAAG,EAAG;AACrB,UAAM,IAAI;AAAA,EACd;AACA,SAAO,IAAI,KAAK,KAAK,EAAE,MAAM,GAAG,GAAG;AACvC;AAEA,IAAM,QAAQ,CAAC,OAAkC,KAAK,IAAI,QAAQ,QAAQ,GAAG,EAAE,KAAK,EAAE,MAAM,GAAG,EAAE;AAG1F,IAAM,UAAU,CAAC,OAAgB;AACpC,MAAI,GAAG,QAAQ,GAAG,aAAa,IAAI,mBAAmB,EAAE,EAAG,QAAO;AAClE,QAAM,MAAM,GAAG,QAAQ,YAAY;AACnC,MAAI,QAAQ,SAAS;AACjB,UAAM,QAAQ,GAAG,aAAa,MAAM,KAAK,QAAQ,YAAY;AAC7D,WAAO,SAAS,YAAY,SAAS,WAAW,MAAM,GAAG,aAAa,YAAY,KAAM,GAAwB,KAAK,IAAI,MAAM,GAAG,aAAa,YAAY,CAAC;AAAA,EAChK;AACA,MAAI,QAAQ,YAAY,QAAQ,WAAY,QAAO,MAAM,GAAG,aAAa,YAAY,CAAC;AACtF,SAAO,MAAM,GAAG,aAAa,YAAY,KAAK,GAAG,aAAa,OAAO,KAAM,GAAmB,aAAa,GAAG,WAAW;AAC7H;AAGO,IAAM,YAAY,CAAC,MAAoC;AAC1D,QAAM,SAAS,EAAE,kBAAkB,UAAU,EAAE,SAAS;AACxD,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,MAAM,OAAO,QAAQ,WAAW;AACtC,QAAM,KAAK,OAAO;AAClB,QAAM,MAAM,SAAS;AACrB,SAAO;AAAA,IACH,GAAG,KAAK,MAAM,EAAE,OAAO;AAAA,IACvB,GAAG,KAAK,MAAM,EAAE,UAAU,OAAO,OAAO;AAAA,IACxC,IAAI,OAAO;AAAA,IACX,IAAI,OAAO;AAAA,IACX,MAAM,KAAK,IAAI,IAAI,cAAc,SAAS,MAAM,gBAAgB,CAAC;AAAA,IACjE,UAAU,WAAW,EAAE;AAAA,IACvB,OAAO,MAAM,QAAQ,GAAG,IAAI;AAAA,IAC5B,aAAa,CAAC,CAAC;AAAA,EACnB;AACJ;AAMO,IAAM,eAAN,MAAmB;AAAA,EAGtB,YACqB,WAAW,KACX,SAAS,IACT,QAAQ,GAC3B;AAHmB;AACA;AACA;AAAA,EAClB;AAAA,EAHkB;AAAA,EACA;AAAA,EACA;AAAA,EALb,SAAgD,CAAC;AAAA,EACjD,QAAQ;AAAA,EAMhB,KAAK,GAAW,GAAW,GAAW;AAClC,UAAM,OAAO,KAAK,OAAO,OAAO,CAAC,MAAM,IAAI,EAAE,KAAK,KAAK,YAAY,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,IAAI,CAAC,KAAK,KAAK,MAAM;AAC9G,QAAI,KAAK,WAAW,KAAK,OAAO,OAAQ,MAAK,QAAQ;AACrD,SAAK,SAAS,CAAC,GAAG,MAAM,EAAE,GAAG,GAAG,EAAE,CAAC;AACnC,QAAI,KAAK,OAAO,UAAU,KAAK,SAAS,CAAC,KAAK,OAAO;AACjD,WAAK,QAAQ;AACb,aAAO;AAAA,IACX;AACA,WAAO;AAAA,EACX;AACJ;AAGO,IAAM,YAAY,MAAM;AAC3B,QAAM,MAAM,SAAS;AACrB,QAAM,IAAI,KAAK,IAAI,IAAI,cAAc,SAAS,MAAM,gBAAgB,CAAC;AACrE,MAAI,CAAC,EAAG,QAAO;AACf,SAAO,KAAK,IAAI,KAAK,KAAK,OAAQ,OAAO,UAAU,OAAO,eAAe,IAAK,GAAG,CAAC;AACtF;","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,8 +19,27 @@ 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;
|
|
35
|
+
/**
|
|
36
|
+
* Error tracking for Build › Errors (default true): uncaught errors and unhandled promise rejections, with the page
|
|
37
|
+
* and the last pages and clicks, under the normal analytics consent. Report errors you catch with
|
|
38
|
+
* `useCockpit().captureError(error)`.
|
|
39
|
+
*/
|
|
40
|
+
errors?: boolean;
|
|
41
|
+
/** Your release (a version or a commit), so MRRocket can tell which release an error came with. */
|
|
42
|
+
release?: string;
|
|
24
43
|
/**
|
|
25
44
|
* Affiliate referrals (`?ref=` links and MRRocket's tracked links). "consent" (default): stored in the first-party
|
|
26
45
|
* `mrr_ref` cookie only while consent is "cookies", in memory otherwise. "always": stored whatever the consent (only
|
|
@@ -38,17 +57,33 @@ export interface CockpitProviderProps {
|
|
|
38
57
|
* `preview` forces variants for this render, for example from the `ck_preview` search param on the server,
|
|
39
58
|
* so a Cockpit preview never flickers. Without it the provider reads the param itself after the first render.
|
|
40
59
|
*/
|
|
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
|
-
/**
|
|
60
|
+
export declare const CockpitProvider: ({ apiKey: apiKeyProp, host: hostProp, preview: forced, embedded: embeddedProp, config: bootstrapConfig, distinctId: bootstrapId, consent: consentProp, respectDnt, replay: replayProp, heatmaps, surveys, errors, release, attribution, referralParam, children, }: CockpitProviderProps) => import("react").JSX.Element;
|
|
61
|
+
/**
|
|
62
|
+
* `track`, `identify`, `captureError`, the consent mode, the replay consent and their setter, the visitor id and whether
|
|
63
|
+
* the experiments and flags have loaded. `setConsent("cookies")` sets the mode, `setConsent({ replay: true })` the
|
|
64
|
+
* replay consent.
|
|
65
|
+
*/
|
|
43
66
|
export declare const useCockpit: () => {
|
|
44
67
|
track: (event: string, props?: Record<string, unknown>) => void;
|
|
45
68
|
identify: (id: string, props?: Record<string, unknown>) => void;
|
|
69
|
+
captureError: (error: unknown) => void;
|
|
46
70
|
distinctId: string;
|
|
47
71
|
ready: boolean;
|
|
48
72
|
embedded: boolean;
|
|
49
73
|
consent: ConsentMode;
|
|
50
|
-
|
|
74
|
+
replay: boolean;
|
|
75
|
+
setConsent: (choice: ConsentChoice) => void;
|
|
51
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* A feature flag from MRRocket › Build › Feature flags: true while it is on for this visitor (its audience and rollout
|
|
79
|
+
* in Production), false while it is off, unknown, or the flags have not loaded yet (`useCockpit().ready`). The person's
|
|
80
|
+
* properties from `identify(id, props)` feed the audience conditions ("Email", "Plan" …).
|
|
81
|
+
*
|
|
82
|
+
* const newCheckout = useFlag("new-checkout");
|
|
83
|
+
*/
|
|
84
|
+
export declare const useFlag: (key: string) => boolean;
|
|
85
|
+
/** The variant of a multivariate flag for this visitor ("control", "test" …), or null while the flag is off. */
|
|
86
|
+
export declare const useFlagVariant: (key: string) => string | null;
|
|
52
87
|
/** The variant this visitor sees, for example `"control"` or `"a"`. Records the exposure. */
|
|
53
88
|
export declare const useVariant: (key: string) => VariantId;
|
|
54
89
|
/** Renders the element for the visitor's variant; control when the variant has no element. */
|