@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.ge03fd3a

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.
Files changed (115) hide show
  1. package/.claude/CLAUDE.md +1 -0
  2. package/README.md +20 -0
  3. package/design/TRACKING.md +210 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/tracking/browser/attach.d.ts +8 -0
  9. package/dist/tracking/browser/attach.d.ts.map +1 -0
  10. package/dist/tracking/browser/attach.js +182 -0
  11. package/dist/tracking/browser/attach.js.map +1 -0
  12. package/dist/tracking/browser/env.d.ts +14 -0
  13. package/dist/tracking/browser/env.d.ts.map +1 -0
  14. package/dist/tracking/browser/env.js +150 -0
  15. package/dist/tracking/browser/env.js.map +1 -0
  16. package/dist/tracking/core/consent.d.ts +21 -0
  17. package/dist/tracking/core/consent.d.ts.map +1 -0
  18. package/dist/tracking/core/consent.js +39 -0
  19. package/dist/tracking/core/consent.js.map +1 -0
  20. package/dist/tracking/core/engagement.d.ts +20 -0
  21. package/dist/tracking/core/engagement.d.ts.map +1 -0
  22. package/dist/tracking/core/engagement.js +52 -0
  23. package/dist/tracking/core/engagement.js.map +1 -0
  24. package/dist/tracking/core/experiments.d.ts +109 -0
  25. package/dist/tracking/core/experiments.d.ts.map +1 -0
  26. package/dist/tracking/core/experiments.js +294 -0
  27. package/dist/tracking/core/experiments.js.map +1 -0
  28. package/dist/tracking/core/hash.d.ts +16 -0
  29. package/dist/tracking/core/hash.d.ts.map +1 -0
  30. package/dist/tracking/core/hash.js +31 -0
  31. package/dist/tracking/core/hash.js.map +1 -0
  32. package/dist/tracking/core/ids.d.ts +6 -0
  33. package/dist/tracking/core/ids.d.ts.map +1 -0
  34. package/dist/tracking/core/ids.js +31 -0
  35. package/dist/tracking/core/ids.js.map +1 -0
  36. package/dist/tracking/core/observable.d.ts +12 -0
  37. package/dist/tracking/core/observable.d.ts.map +1 -0
  38. package/dist/tracking/core/observable.js +32 -0
  39. package/dist/tracking/core/observable.js.map +1 -0
  40. package/dist/tracking/core/queue.d.ts +47 -0
  41. package/dist/tracking/core/queue.d.ts.map +1 -0
  42. package/dist/tracking/core/queue.js +205 -0
  43. package/dist/tracking/core/queue.js.map +1 -0
  44. package/dist/tracking/core/sections.d.ts +54 -0
  45. package/dist/tracking/core/sections.d.ts.map +1 -0
  46. package/dist/tracking/core/sections.js +156 -0
  47. package/dist/tracking/core/sections.js.map +1 -0
  48. package/dist/tracking/core/storage.d.ts +15 -0
  49. package/dist/tracking/core/storage.d.ts.map +1 -0
  50. package/dist/tracking/core/storage.js +72 -0
  51. package/dist/tracking/core/storage.js.map +1 -0
  52. package/dist/tracking/core/tracker.d.ts +122 -0
  53. package/dist/tracking/core/tracker.d.ts.map +1 -0
  54. package/dist/tracking/core/tracker.js +390 -0
  55. package/dist/tracking/core/tracker.js.map +1 -0
  56. package/dist/tracking/core/types.d.ts +130 -0
  57. package/dist/tracking/core/types.d.ts.map +1 -0
  58. package/dist/tracking/core/types.js +69 -0
  59. package/dist/tracking/core/types.js.map +1 -0
  60. package/dist/tracking/core/utm.d.ts +6 -0
  61. package/dist/tracking/core/utm.d.ts.map +1 -0
  62. package/dist/tracking/core/utm.js +30 -0
  63. package/dist/tracking/core/utm.js.map +1 -0
  64. package/dist/tracking/index.d.ts +46 -0
  65. package/dist/tracking/index.d.ts.map +1 -0
  66. package/dist/tracking/index.js +91 -0
  67. package/dist/tracking/index.js.map +1 -0
  68. package/dist/tracking/markup.d.ts +12 -0
  69. package/dist/tracking/markup.d.ts.map +1 -0
  70. package/dist/tracking/markup.js +19 -0
  71. package/dist/tracking/markup.js.map +1 -0
  72. package/dist/tracking/react/Experiment.d.ts +23 -0
  73. package/dist/tracking/react/Experiment.d.ts.map +1 -0
  74. package/dist/tracking/react/Experiment.js +28 -0
  75. package/dist/tracking/react/Experiment.js.map +1 -0
  76. package/dist/tracking/react/TrackingProvider.d.ts +26 -0
  77. package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
  78. package/dist/tracking/react/TrackingProvider.js +45 -0
  79. package/dist/tracking/react/TrackingProvider.js.map +1 -0
  80. package/dist/tracking/react/hooks.d.ts +39 -0
  81. package/dist/tracking/react/hooks.d.ts.map +1 -0
  82. package/dist/tracking/react/hooks.js +96 -0
  83. package/dist/tracking/react/hooks.js.map +1 -0
  84. package/dist/tracking/react/index.d.ts +7 -0
  85. package/dist/tracking/react/index.d.ts.map +1 -0
  86. package/dist/tracking/react/index.js +17 -0
  87. package/dist/tracking/react/index.js.map +1 -0
  88. package/dist/tracking/testing.d.ts +40 -0
  89. package/dist/tracking/testing.d.ts.map +1 -0
  90. package/dist/tracking/testing.js +140 -0
  91. package/dist/tracking/testing.js.map +1 -0
  92. package/package.json +1 -1
  93. package/src/index.tsx +3 -0
  94. package/src/tracking/browser/attach.ts +186 -0
  95. package/src/tracking/browser/env.ts +78 -0
  96. package/src/tracking/core/consent.ts +37 -0
  97. package/src/tracking/core/engagement.ts +47 -0
  98. package/src/tracking/core/experiments.ts +258 -0
  99. package/src/tracking/core/hash.ts +26 -0
  100. package/src/tracking/core/ids.ts +25 -0
  101. package/src/tracking/core/observable.ts +23 -0
  102. package/src/tracking/core/queue.ts +131 -0
  103. package/src/tracking/core/sections.ts +166 -0
  104. package/src/tracking/core/storage.ts +69 -0
  105. package/src/tracking/core/tracker.ts +417 -0
  106. package/src/tracking/core/types.ts +162 -0
  107. package/src/tracking/core/utm.ts +23 -0
  108. package/src/tracking/index.ts +72 -0
  109. package/src/tracking/markup.ts +17 -0
  110. package/src/tracking/react/Experiment.stories.tsx +174 -0
  111. package/src/tracking/react/Experiment.tsx +44 -0
  112. package/src/tracking/react/TrackingProvider.tsx +56 -0
  113. package/src/tracking/react/hooks.ts +106 -0
  114. package/src/tracking/react/index.ts +13 -0
  115. package/src/tracking/testing.ts +108 -0
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
13
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
14
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
15
+ return new (P || (P = Promise))(function (resolve, reject) {
16
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
17
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
18
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
19
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
20
+ });
21
+ };
22
+ var __generator = (this && this.__generator) || function (thisArg, body) {
23
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
24
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
25
+ function verb(n) { return function (v) { return step([n, v]); }; }
26
+ function step(op) {
27
+ if (f) throw new TypeError("Generator is already executing.");
28
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
29
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
30
+ if (y = 0, t) op = [op[0] & 2, t.value];
31
+ switch (op[0]) {
32
+ case 0: case 1: t = op; break;
33
+ case 4: _.label++; return { value: op[1], done: false };
34
+ case 5: _.label++; y = op[1]; op = [0]; continue;
35
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
36
+ default:
37
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
38
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
39
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
40
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
41
+ if (t[2]) _.ops.pop();
42
+ _.trys.pop(); continue;
43
+ }
44
+ op = body.call(thisArg, _);
45
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
46
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
47
+ }
48
+ };
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.createMemoryTracker = void 0;
51
+ var storage_1 = require("./core/storage");
52
+ var tracker_1 = require("./core/tracker");
53
+ var types_1 = require("./core/types");
54
+ var createMemoryTracker = function (options) {
55
+ var _a, _b;
56
+ if (options === void 0) { options = {}; }
57
+ var clock = (_a = options.start) !== null && _a !== void 0 ? _a : Date.UTC(2026, 8, 29, 10, 0, 0);
58
+ var offline = false;
59
+ var timers = new Map();
60
+ var nextTimer = 1;
61
+ var local = (0, storage_1.memoryStorage)();
62
+ var session = (0, storage_1.memoryStorage)();
63
+ var config = (0, types_1.resolveConfig)(__assign({ property: 'test', endpoint: '/collect', experimentsEndpoint: '/experiments' }, options.config));
64
+ var keys = (0, types_1.storageKeys)(config.storagePrefix);
65
+ if (options.consent) {
66
+ local.set(keys.consent, options.consent);
67
+ local.set(keys.consentVersion, String(config.consentVersion));
68
+ }
69
+ if (options.vid)
70
+ local.set(keys.vid, options.vid);
71
+ Object.entries((_b = options.seed) !== null && _b !== void 0 ? _b : {}).forEach(function (_a) {
72
+ var k = _a[0], v = _a[1];
73
+ return local.set(k, v);
74
+ });
75
+ var page = __assign({ origin: 'https://example.com', path: '/', search: '', title: 'Example', referrer: '', lang: 'en-US', viewport: [1440, 900], device: 'desktop' }, options.page);
76
+ var sent = [];
77
+ var tracker = new tracker_1.Tracker(config, {
78
+ local: local,
79
+ session: session,
80
+ now: function () { return clock; },
81
+ setTimer: function (fn, ms) {
82
+ var id = nextTimer++;
83
+ timers.set(id, { at: clock + ms, fn: fn });
84
+ return id;
85
+ },
86
+ clearTimer: function (h) { return void timers.delete(h); },
87
+ page: function () { return page; },
88
+ transport: {
89
+ send: function (body, mode) { return __awaiter(void 0, void 0, void 0, function () {
90
+ return __generator(this, function (_a) {
91
+ if (offline)
92
+ return [2 /*return*/, false];
93
+ sent.push({ batch: JSON.parse(body), mode: mode });
94
+ return [2 /*return*/, true];
95
+ });
96
+ }); },
97
+ },
98
+ fetchJson: function () { return __awaiter(void 0, void 0, void 0, function () {
99
+ return __generator(this, function (_a) {
100
+ if (!options.experiments || options.experiments === 'fail')
101
+ throw new Error('no experiments endpoint');
102
+ return [2 /*return*/, { experiments: typeof options.experiments === 'function' ? options.experiments() : options.experiments }];
103
+ });
104
+ }); },
105
+ });
106
+ var advance = function (ms) { return __awaiter(void 0, void 0, void 0, function () {
107
+ var until, due;
108
+ return __generator(this, function (_a) {
109
+ switch (_a.label) {
110
+ case 0:
111
+ until = clock + ms;
112
+ _a.label = 1;
113
+ case 1:
114
+ due = Array.from(timers.entries()).filter(function (_a) {
115
+ var t = _a[1];
116
+ return t.at <= until;
117
+ }).sort(function (a, b) { return a[1].at - b[1].at; })[0];
118
+ if (!due)
119
+ return [3 /*break*/, 4];
120
+ timers.delete(due[0]);
121
+ clock = Math.max(clock, due[1].at);
122
+ due[1].fn();
123
+ return [4 /*yield*/, Promise.resolve()];
124
+ case 2:
125
+ _a.sent();
126
+ _a.label = 3;
127
+ case 3: return [3 /*break*/, 1];
128
+ case 4:
129
+ clock = until;
130
+ return [4 /*yield*/, new Promise(function (r) { return setTimeout(r, 0); })];
131
+ case 5:
132
+ _a.sent();
133
+ return [2 /*return*/];
134
+ }
135
+ });
136
+ }); };
137
+ return { tracker: tracker, sent: sent, local: local, session: session, keys: keys, page: page, advance: advance, now: function () { return clock; }, setOffline: function (o) { return (offline = o); } };
138
+ };
139
+ exports.createMemoryTracker = createMemoryTracker;
140
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../../src/tracking/testing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qoretechnologies/reqraft",
3
- "version": "0.10.56",
3
+ "version": "0.10.57-pr.127.ge03fd3a",
4
4
  "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.tsx CHANGED
@@ -77,3 +77,6 @@ export * from './components/ticketThread';
77
77
  export * from './components/ticketReferences';
78
78
  export * from './components/searchFilterBar';
79
79
  export * from './components/codeSize';
80
+ // First-party tracking, consent and A/B testing (design/TRACKING.md). Also importable
81
+ // on its own, without the rest of Reqraft: `@qoretechnologies/reqraft/dist/tracking`.
82
+ export * from './tracking';
@@ -0,0 +1,186 @@
1
+ import { effectiveRatio } from '../core/sections';
2
+ import type { Tracker } from '../core/tracker';
3
+
4
+ /**
5
+ * Connects a `Tracker` to the page: tab visibility, page leave, interactions,
6
+ * scroll depth, the heartbeat, section visibility (IntersectionObserver on every
7
+ * `[data-track-section]`) and clicks (one delegated listener for
8
+ * `[data-track-click]`). Returns a function that removes all of it.
9
+ *
10
+ * Markup contract (see design/TRACKING.md):
11
+ * data-track-section="<id>" a section: visible time, seen, exit section
12
+ * data-track-click="<target>" a tracked click (`get-started`, `contact`, …)
13
+ * data-track-placement="<where>" optional; defaults to the enclosing section id
14
+ * data-track-label="<text>" optional; defaults to the element's text
15
+ */
16
+
17
+ const SECTION_SELECTOR = '[data-track-section]';
18
+ const THRESHOLDS = Array.from({ length: 21 }, (_, i) => i / 20);
19
+ const TICK_MS = 250;
20
+ const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart', 'mousemove', 'scroll'] as const;
21
+
22
+ export interface IAttached {
23
+ detach: () => void;
24
+ /** Observes an element under a synthetic id (an experiment's wrapped element); not in the page summary. */
25
+ observeElement: (el: Element, id: string) => () => void;
26
+ }
27
+
28
+ const clean = (s: string | null | undefined, max = 80) => {
29
+ const t = (s ?? '').replace(/\s+/g, ' ').trim();
30
+ return t ? t.slice(0, max) : null;
31
+ };
32
+
33
+ export const attachToPage = (tracker: Tracker, heartbeatMs: number): IAttached => {
34
+ const cleanup: (() => void)[] = [];
35
+ const on = (target: EventTarget, type: string, fn: EventListener, opts?: AddEventListenerOptions) => {
36
+ target.addEventListener(type, fn, opts);
37
+ cleanup.push(() => target.removeEventListener(type, fn, opts));
38
+ };
39
+ const safe = (fn: () => void) => () => {
40
+ try {
41
+ fn();
42
+ } catch {
43
+ /* never into the page */
44
+ }
45
+ };
46
+
47
+ // ── tab visibility and page leave
48
+ on(document, 'visibilitychange', safe(() => tracker.setTabVisible(document.visibilityState === 'visible')));
49
+ on(window, 'pagehide', safe(() => tracker.leave()));
50
+ if (document.visibilityState !== 'visible') tracker.setTabVisible(false);
51
+
52
+ // ── engagement (throttled to one signal per second)
53
+ let lastActivity = 0;
54
+ const activity = safe(() => {
55
+ const now = Date.now();
56
+ if (now - lastActivity < 1000) return;
57
+ lastActivity = now;
58
+ tracker.activity();
59
+ });
60
+ ACTIVITY_EVENTS.forEach((t) => on(window, t, activity, { passive: true, capture: true }));
61
+
62
+ // ── scroll depth (once per frame)
63
+ let scrollQueued = false;
64
+ const measureScroll = safe(() => {
65
+ scrollQueued = false;
66
+ const doc = document.documentElement;
67
+ const scrollable = Math.max(doc.scrollHeight, document.body?.scrollHeight ?? 0);
68
+ const seen = window.scrollY + window.innerHeight;
69
+ tracker.scrolled(scrollable > 0 ? (seen / scrollable) * 100 : 100);
70
+ });
71
+ on(window, 'scroll', () => {
72
+ if (scrollQueued) return;
73
+ scrollQueued = true;
74
+ requestAnimationFrame(measureScroll);
75
+ }, { passive: true });
76
+
77
+ // ── heartbeat while visible, and the section clock
78
+ const heartbeat = setInterval(safe(() => document.visibilityState === 'visible' && tracker.heartbeat()), heartbeatMs);
79
+ const tick = setInterval(safe(() => tracker.sections.tick(tracker.now())), TICK_MS);
80
+ cleanup.push(() => clearInterval(heartbeat), () => clearInterval(tick));
81
+
82
+ // ── sections
83
+ const ratios = new Map<Element, number>();
84
+ const ids = new Map<Element, string>();
85
+ const report = new Map<string, boolean>();
86
+ const apply = (id: string) => {
87
+ let best = 0;
88
+ ids.forEach((other, el) => other === id && (best = Math.max(best, ratios.get(el) ?? 0)));
89
+ tracker.sections.update(id, best, tracker.now());
90
+ };
91
+ const io =
92
+ typeof IntersectionObserver !== 'undefined'
93
+ ? new IntersectionObserver(
94
+ (entries) =>
95
+ safe(() => {
96
+ const vh = window.innerHeight || document.documentElement.clientHeight;
97
+ entries.forEach((e) => {
98
+ const id = ids.get(e.target);
99
+ if (!id) return;
100
+ ratios.set(e.target, effectiveRatio(e.intersectionRatio, e.intersectionRect.height, e.rootBounds?.height ?? vh));
101
+ apply(id);
102
+ });
103
+ })(),
104
+ { threshold: THRESHOLDS },
105
+ )
106
+ : null;
107
+
108
+ const track = (el: Element, id: string, inSummary: boolean) => {
109
+ if (ids.get(el) === id) return;
110
+ ids.set(el, id);
111
+ report.set(id, (report.get(id) ?? false) || inSummary);
112
+ tracker.sections.register(id, inSummary);
113
+ io?.observe(el);
114
+ };
115
+ const untrack = (el: Element) => {
116
+ const id = ids.get(el);
117
+ if (!id) return;
118
+ io?.unobserve(el);
119
+ ids.delete(el);
120
+ ratios.delete(el);
121
+ apply(id);
122
+ };
123
+
124
+ const scan = safe(() => {
125
+ const present = new Set<Element>(Array.from(document.querySelectorAll(SECTION_SELECTOR)));
126
+ ids.forEach((id, el) => report.get(id) && !present.has(el) && !el.isConnected && untrack(el));
127
+ present.forEach((el) => {
128
+ const id = (el as HTMLElement).dataset.trackSection;
129
+ if (id) track(el, id, true);
130
+ });
131
+ });
132
+
133
+ // Re-measure everything at a new page view: the section clock starts from zero,
134
+ // and re-observing makes the observer report every element's current state.
135
+ const remeasure = safe(() => {
136
+ scan();
137
+ ids.forEach((_id, el) => {
138
+ io?.unobserve(el);
139
+ io?.observe(el);
140
+ });
141
+ });
142
+ cleanup.push(tracker.pageViewId.subscribe(remeasure));
143
+
144
+ let scanTimer: ReturnType<typeof setTimeout> | null = null;
145
+ const mo =
146
+ typeof MutationObserver !== 'undefined'
147
+ ? new MutationObserver(() => {
148
+ if (scanTimer) return;
149
+ scanTimer = setTimeout(() => {
150
+ scanTimer = null;
151
+ scan();
152
+ }, 150);
153
+ })
154
+ : null;
155
+ mo?.observe(document.body, { childList: true, subtree: true });
156
+ scan();
157
+ cleanup.push(() => {
158
+ mo?.disconnect();
159
+ io?.disconnect();
160
+ if (scanTimer) clearTimeout(scanTimer);
161
+ });
162
+
163
+ // ── clicks: one delegated listener
164
+ const onClick = (e: Event) =>
165
+ safe(() => {
166
+ const el = (e.target as Element | null)?.closest?.('[data-track-click]') as HTMLElement | null;
167
+ if (!el) return;
168
+ const d = el.dataset;
169
+ const link = el.closest('a');
170
+ tracker.click({
171
+ target: d.trackClick || 'unknown',
172
+ placement: d.trackPlacement || (el.closest(SECTION_SELECTOR) as HTMLElement | null)?.dataset.trackSection || null,
173
+ label: clean(d.trackLabel) ?? clean(el.innerText) ?? clean(el.getAttribute('aria-label')),
174
+ href: d.trackHref || link?.href || null,
175
+ });
176
+ })();
177
+ on(document, 'click', onClick, { capture: true });
178
+
179
+ return {
180
+ detach: () => cleanup.forEach((fn) => fn()),
181
+ observeElement: (el, id) => {
182
+ track(el, id, false);
183
+ return () => untrack(el);
184
+ },
185
+ };
186
+ };
@@ -0,0 +1,78 @@
1
+ import type { ITransport } from '../core/queue';
2
+ import { safeStorage } from '../core/storage';
3
+ import type { IPageInfo, ITrackerEnv } from '../core/tracker';
4
+
5
+ /**
6
+ * Sends a batch. `beacon` (page going away): `navigator.sendBeacon`, falling back
7
+ * to `fetch(…, { keepalive: true })` when the beacon is refused (too big, or the
8
+ * browser has none). Any HTTP answer counts as delivered — the collector always
9
+ * answers 204, and a 404 (collector not installed) must not turn into a retry loop;
10
+ * only a network error keeps the batch for the next flush.
11
+ */
12
+ export const browserTransport = (endpoint: string): ITransport => ({
13
+ async send(body, mode) {
14
+ if (mode === 'beacon' && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
15
+ try {
16
+ if (navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }))) return true;
17
+ } catch {
18
+ /* fall through to fetch */
19
+ }
20
+ }
21
+ try {
22
+ await fetch(endpoint, {
23
+ method: 'POST',
24
+ body,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ // Batches stay under 60 KB (queue.ts), inside keepalive's 64 KB budget.
27
+ keepalive: true,
28
+ credentials: 'same-origin',
29
+ });
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ },
35
+ });
36
+
37
+ const fetchJson = async (url: string, timeoutMs: number): Promise<unknown> => {
38
+ const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
39
+ const timer = setTimeout(() => ctrl?.abort(), timeoutMs);
40
+ try {
41
+ const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: ctrl?.signal, credentials: 'same-origin' });
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
+ return await res.json();
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ };
48
+
49
+ /** desktop / tablet / mobile from the user agent (targeting only; the server classifies for reports). */
50
+ export const deviceOf = (ua: string): IPageInfo['device'] => {
51
+ if (/iPad|Tablet|PlayBook|Silk|Android(?!.*Mobile)/i.test(ua)) return 'tablet';
52
+ if (/Mobi|iPhone|iPod|Android.*Mobile|Opera Mini|IEMobile/i.test(ua)) return 'mobile';
53
+ return 'desktop';
54
+ };
55
+
56
+ const readPage = (): IPageInfo => ({
57
+ origin: window.location.origin,
58
+ path: window.location.pathname,
59
+ search: window.location.search,
60
+ title: document.title,
61
+ referrer: document.referrer,
62
+ lang: navigator.language || 'en',
63
+ viewport: [window.innerWidth, window.innerHeight],
64
+ device: deviceOf(navigator.userAgent || ''),
65
+ });
66
+
67
+ export const browserEnv = (endpoint: string): ITrackerEnv => ({
68
+ local: safeStorage(() => window.localStorage),
69
+ session: safeStorage(() => window.sessionStorage),
70
+ transport: browserTransport(endpoint),
71
+ now: () => Date.now(),
72
+ setTimer: (fn, ms) => setTimeout(fn, ms),
73
+ clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
74
+ page: readPage,
75
+ fetchJson,
76
+ // eslint-disable-next-line no-console
77
+ log: (...args) => console.info(...args),
78
+ });
@@ -0,0 +1,37 @@
1
+ import type { IStorage } from './storage';
2
+ import { Observable } from './observable';
3
+ import { KEYS, type TConsentState, type TStorageKeys } from './types';
4
+
5
+ /**
6
+ * The visitor's answer (§3 of the contract): `localStorage["<prefix>consent"]` is
7
+ * `granted` | `denied`, `<prefix>consent.v` the version of the text they answered
8
+ * (prefix `qa.` by default). An answer to an older version counts as no answer, so
9
+ * the consent prompt asks again.
10
+ */
11
+ export class ConsentStore {
12
+ readonly state: Observable<TConsentState>;
13
+
14
+ constructor(
15
+ private readonly storage: IStorage,
16
+ private readonly version: number,
17
+ private readonly keys: TStorageKeys = KEYS,
18
+ ) {
19
+ this.state = new Observable<TConsentState>(this.read());
20
+ }
21
+
22
+ private read(): TConsentState {
23
+ const answer = this.storage.get(this.keys.consent);
24
+ const version = Number(this.storage.get(this.keys.consentVersion) ?? 0);
25
+ if ((answer === 'granted' || answer === 'denied') && version >= this.version) return answer;
26
+ return 'unknown';
27
+ }
28
+
29
+ get = (): TConsentState => this.state.get();
30
+ granted = (): boolean => this.state.get() === 'granted';
31
+
32
+ answer(state: 'granted' | 'denied') {
33
+ this.storage.set(this.keys.consent, state);
34
+ this.storage.set(this.keys.consentVersion, String(this.version));
35
+ this.state.set(state);
36
+ }
37
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Engaged time: the tab is visible AND the visitor did something (scroll, pointer,
3
+ * key, touch) within the last `windowMs`. Each interaction extends the engaged
4
+ * stretch to `interaction + windowMs`; hiding the tab ends it at once.
5
+ */
6
+ export class EngagementClock {
7
+ private engagedMs = 0;
8
+ /** Start and end of the current engaged stretch (end is in the future while active). */
9
+ private from: number | null = null;
10
+ private until = 0;
11
+ private tabVisible = true;
12
+
13
+ constructor(private readonly windowMs: number) {}
14
+
15
+ reset() {
16
+ this.engagedMs = 0;
17
+ this.from = null;
18
+ this.until = 0;
19
+ }
20
+
21
+ activity(now: number) {
22
+ if (!this.tabVisible) return;
23
+ if (this.from != null && now <= this.until) {
24
+ this.until = now + this.windowMs;
25
+ return;
26
+ }
27
+ this.close(now);
28
+ this.from = now;
29
+ this.until = now + this.windowMs;
30
+ }
31
+
32
+ setTabVisible(visible: boolean, now: number) {
33
+ if (!visible) this.close(now);
34
+ this.tabVisible = visible;
35
+ }
36
+
37
+ private close(now: number) {
38
+ if (this.from == null) return;
39
+ this.engagedMs += Math.max(0, Math.min(now, this.until) - this.from);
40
+ this.from = null;
41
+ }
42
+
43
+ total(now: number): number {
44
+ const open = this.from != null ? Math.max(0, Math.min(now, this.until) - this.from) : 0;
45
+ return Math.round(this.engagedMs + open);
46
+ }
47
+ }