@leapio/web 0.0.0-stage → 1.0.1

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/dist/index.mjs ADDED
@@ -0,0 +1,3622 @@
1
+ //#region src/cookie.ts
2
+ const LEGACY_UID_COOKIE = "lgspt_uid";
3
+ const ONE_DAY_IN_SECONDS = 86400;
4
+ const ONE_YEAR_IN_SECONDS = 360 * ONE_DAY_IN_SECONDS;
5
+ const setCookie = ({ name, value, expiresInSeconds, domain }) => {
6
+ let date = /* @__PURE__ */ new Date();
7
+ date.setTime(date.getTime() + expiresInSeconds * 1e3);
8
+ const expires = "; expires=" + date.toUTCString();
9
+ const domainProperty = domain ? `; domain=${domain}` : "";
10
+ document.cookie = name + "=" + (value || "") + domainProperty + expires + "; path=/";
11
+ };
12
+ const getCookie = (name) => {
13
+ const nameEQ = name + "=";
14
+ const ca = document.cookie.split(";");
15
+ for (var i = 0; i < ca.length; i++) {
16
+ let c = ca[i];
17
+ while (c.charAt(0) == " ") c = c.substring(1, c.length);
18
+ if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
19
+ }
20
+ return null;
21
+ };
22
+ const eraseCookie = (name, domain) => {
23
+ const domainProperty = domain ? `; Domain=${domain}` : "";
24
+ document.cookie = name + `=; Path=/${domainProperty}; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
25
+ };
26
+ //#endregion
27
+ //#region src/safe-storage.ts
28
+ const safeLocalStorage = () => {
29
+ try {
30
+ if (typeof window === "undefined" || !window.localStorage) return null;
31
+ const probeKey = "__leapio_probe__";
32
+ window.localStorage.setItem(probeKey, "1");
33
+ window.localStorage.removeItem(probeKey);
34
+ return window.localStorage;
35
+ } catch (_unused) {
36
+ return null;
37
+ }
38
+ };
39
+ //#endregion
40
+ //#region src/anonymous-id.ts
41
+ const ANONYMOUS_ID_KEY = "leapio_anonymous_id";
42
+ const LEGACY_ANONYMOUS_ID_KEY = "lgspt_anonymous_id";
43
+ const MAX_ANONYMOUS_ID_LENGTH = 64;
44
+ /**
45
+ * Generate a v4 UUID. Prefer crypto.randomUUID() (modern browsers); fall back
46
+ * to a Math.random-based implementation for older environments.
47
+ */
48
+ const generateAnonymousId = () => {
49
+ try {
50
+ const c = typeof crypto !== "undefined" ? crypto : void 0;
51
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
52
+ } catch (_unused) {}
53
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
54
+ const r = Math.random() * 16 | 0;
55
+ return (c === "x" ? r : r & 3 | 8).toString(16);
56
+ });
57
+ };
58
+ const tryReadCookie$1 = (name) => {
59
+ try {
60
+ return getCookie(name);
61
+ } catch (_unused2) {
62
+ return null;
63
+ }
64
+ };
65
+ const tryWriteCookie$1 = (value, opts) => {
66
+ try {
67
+ var _opts$cookieExpiratio, _opts$cookieDomain;
68
+ setCookie({
69
+ name: ANONYMOUS_ID_KEY,
70
+ value,
71
+ expiresInSeconds: (_opts$cookieExpiratio = opts.cookieExpirationInSeconds) !== null && _opts$cookieExpiratio !== void 0 ? _opts$cookieExpiratio : ONE_YEAR_IN_SECONDS,
72
+ domain: (_opts$cookieDomain = opts.cookieDomain) !== null && _opts$cookieDomain !== void 0 ? _opts$cookieDomain : null
73
+ });
74
+ return getCookie(ANONYMOUS_ID_KEY) === value;
75
+ } catch (_unused3) {
76
+ return false;
77
+ }
78
+ };
79
+ const tryReadLocalStorage$1 = (key) => {
80
+ const ls = safeLocalStorage();
81
+ if (!ls) return null;
82
+ try {
83
+ return ls.getItem(key);
84
+ } catch (_unused4) {
85
+ return null;
86
+ }
87
+ };
88
+ const tryWriteLocalStorage$1 = (value) => {
89
+ const ls = safeLocalStorage();
90
+ if (!ls) return false;
91
+ try {
92
+ ls.setItem(ANONYMOUS_ID_KEY, value);
93
+ return true;
94
+ } catch (_unused5) {
95
+ return false;
96
+ }
97
+ };
98
+ let inMemoryAnonymousId = null;
99
+ /**
100
+ * Read existing anonymous id from cookie -> legacy SDK cookie -> localStorage
101
+ * -> in-memory. The legacy `lgspt_uid` cookie was the old anonymous browser id;
102
+ * migrating it preserves pre-identify history without continuing to send it as
103
+ * `user_id`.
104
+ */
105
+ const readAnonymousId = (opts) => {
106
+ var _tryReadLocalStorage;
107
+ if (!opts.cookiesDisabled) {
108
+ var _tryReadCookie;
109
+ const fromCookie = (_tryReadCookie = tryReadCookie$1("leapio_anonymous_id")) !== null && _tryReadCookie !== void 0 ? _tryReadCookie : tryReadCookie$1(LEGACY_ANONYMOUS_ID_KEY);
110
+ if (fromCookie) return fromCookie;
111
+ const fromLegacyCookie = tryReadCookie$1(LEGACY_UID_COOKIE);
112
+ if (fromLegacyCookie && fromLegacyCookie.length <= MAX_ANONYMOUS_ID_LENGTH) return fromLegacyCookie;
113
+ }
114
+ const fromLs = (_tryReadLocalStorage = tryReadLocalStorage$1("leapio_anonymous_id")) !== null && _tryReadLocalStorage !== void 0 ? _tryReadLocalStorage : tryReadLocalStorage$1(LEGACY_ANONYMOUS_ID_KEY);
115
+ if (fromLs) return fromLs;
116
+ return inMemoryAnonymousId;
117
+ };
118
+ /**
119
+ * Persist the anonymous id through the storage chain. Cookie attempted first
120
+ * (unless disabled), then localStorage, then in-memory as last resort.
121
+ */
122
+ const writeAnonymousId = (value, opts) => {
123
+ if (!opts.cookiesDisabled) tryWriteCookie$1(value, opts);
124
+ tryWriteLocalStorage$1(value);
125
+ inMemoryAnonymousId = value;
126
+ };
127
+ /** Clear the anonymous id from every storage layer. Used by reset(). */
128
+ const clearAnonymousId = (opts) => {
129
+ for (const name of [
130
+ ANONYMOUS_ID_KEY,
131
+ LEGACY_ANONYMOUS_ID_KEY,
132
+ LEGACY_UID_COOKIE
133
+ ]) try {
134
+ var _opts$cookieDomain2;
135
+ eraseCookie(name, (_opts$cookieDomain2 = opts.cookieDomain) !== null && _opts$cookieDomain2 !== void 0 ? _opts$cookieDomain2 : null);
136
+ } catch (_unused6) {}
137
+ const ls = safeLocalStorage();
138
+ if (ls) try {
139
+ ls.removeItem(ANONYMOUS_ID_KEY);
140
+ ls.removeItem(LEGACY_ANONYMOUS_ID_KEY);
141
+ } catch (_unused7) {}
142
+ inMemoryAnonymousId = null;
143
+ };
144
+ /**
145
+ * Get-or-create: return existing anonymous id if present, otherwise generate
146
+ * a new one and persist it.
147
+ */
148
+ const ensureAnonymousId = (opts) => {
149
+ const existing = readAnonymousId(opts);
150
+ if (existing) {
151
+ inMemoryAnonymousId = existing;
152
+ writeAnonymousId(existing, opts);
153
+ return existing;
154
+ }
155
+ const fresh = generateAnonymousId();
156
+ writeAnonymousId(fresh, opts);
157
+ return fresh;
158
+ };
159
+ /** Force-rotate: generate a new anonymous id, clear all old layers, persist new. */
160
+ const rotateAnonymousId = (opts) => {
161
+ clearAnonymousId(opts);
162
+ const fresh = generateAnonymousId();
163
+ writeAnonymousId(fresh, opts);
164
+ return fresh;
165
+ };
166
+ //#endregion
167
+ //#region src/ac-overrides.ts
168
+ /** localStorage key prefix; the public key is appended for per-project dedup. */
169
+ const AC_OVERRIDES_KEY_PREFIX = "leapio_acov_";
170
+ const keyFor$1 = (publicKey) => `${AC_OVERRIDES_KEY_PREFIX}${publicKey}`;
171
+ /** The per-type keys the SDK reports (the server-controllable types). */
172
+ const REPORTED_TYPE_KEYS = [
173
+ "outboundLinks",
174
+ "fileDownloads",
175
+ "formSubmissions",
176
+ "mediaPlayback"
177
+ ];
178
+ /**
179
+ * Build the EXPLICIT snippet-override report from the normalized snippet shape
180
+ * (`{ master, types }` produced by `normalizeSnippet`) plus the legacy
181
+ * `enableAutoClicks` flag. Only keys the snippet explicitly set are included:
182
+ *
183
+ * - `master` is included (as `true`/`false`) only when the snippet/host set it.
184
+ * `enableAutoClicks: true` counts as a snippet-side master-enable (matching the
185
+ * resolver), but only when the snippet did not already set the master.
186
+ * - `types` is included only when the snippet explicitly set one of the
187
+ * server-controllable types (`outboundLinks` / `fileDownloads` /
188
+ * `formSubmissions` / `mediaPlayback`). `clicks` / `inputChanges` are
189
+ * snippet-only knobs the server doesn't model, so they are not reported.
190
+ *
191
+ * Returns an empty object `{}` when the snippet set nothing reportable (the
192
+ * common case). `master` carries the explicit boolean; absent means unset.
193
+ */
194
+ const buildSnippetOverrideReport = (normalized, enableAutoClicks) => {
195
+ const report = {};
196
+ const master = typeof normalized.master === "boolean" ? normalized.master : enableAutoClicks ? true : void 0;
197
+ if (typeof master === "boolean") report.master = master;
198
+ const types = {};
199
+ for (const key of REPORTED_TYPE_KEYS) {
200
+ const value = normalized.types[key];
201
+ if (typeof value === "boolean") types[key] = value;
202
+ }
203
+ if (Object.keys(types).length > 0) report.types = types;
204
+ return report;
205
+ };
206
+ /**
207
+ * Whether a built report carries any explicit override (a master or any type).
208
+ * The empty report `{}` means "the snippet overrode nothing."
209
+ */
210
+ const hasSnippetOverrides = (report) => "master" in report || report.types !== void 0;
211
+ /**
212
+ * Stable fingerprint of a snippet-override report: the JSON of the report with
213
+ * type keys emitted in a fixed order, so logically-equal reports always produce
214
+ * an identical string regardless of insertion order. Returns "" for the empty
215
+ * (no-overrides) report.
216
+ */
217
+ const computeOverrideFingerprint = (report) => {
218
+ if (!hasSnippetOverrides(report)) return "";
219
+ const canonical = {};
220
+ if (typeof report.master === "boolean") canonical.master = report.master;
221
+ if (report.types) {
222
+ const orderedTypes = {};
223
+ for (const key of REPORTED_TYPE_KEYS) {
224
+ const value = report.types[key];
225
+ if (typeof value === "boolean") orderedTypes[key] = value;
226
+ }
227
+ canonical.types = orderedTypes;
228
+ }
229
+ try {
230
+ return JSON.stringify(canonical);
231
+ } catch (_unused) {
232
+ return "";
233
+ }
234
+ };
235
+ /** Read the last-reported fingerprint for a public key. Never throws; "" when absent. */
236
+ const readReportedFingerprint = (publicKey) => {
237
+ if (!publicKey) return "";
238
+ const ls = safeLocalStorage();
239
+ if (!ls) return "";
240
+ try {
241
+ var _ls$getItem;
242
+ return (_ls$getItem = ls.getItem(keyFor$1(publicKey))) !== null && _ls$getItem !== void 0 ? _ls$getItem : "";
243
+ } catch (_unused2) {
244
+ return "";
245
+ }
246
+ };
247
+ /** Persist the last-reported fingerprint for a public key. Best-effort; never throws. */
248
+ const writeReportedFingerprint = (publicKey, fingerprint) => {
249
+ if (!publicKey) return;
250
+ const ls = safeLocalStorage();
251
+ if (!ls) return;
252
+ try {
253
+ ls.setItem(keyFor$1(publicKey), fingerprint);
254
+ } catch (_unused3) {}
255
+ };
256
+ /** Remove the stored fingerprint for a public key. Never throws. */
257
+ const clearReportedFingerprint = (publicKey) => {
258
+ if (!publicKey) return;
259
+ const ls = safeLocalStorage();
260
+ if (!ls) return;
261
+ try {
262
+ ls.removeItem(keyFor$1(publicKey));
263
+ } catch (_unused4) {}
264
+ };
265
+ /**
266
+ * Decide whether to attach `_acOverrides` to the next track request, and update
267
+ * the stored fingerprint so the same override set is reported at most once per
268
+ * browser. All localStorage access is best-effort and never throws.
269
+ *
270
+ * Cases:
271
+ * - Snippet has overrides AND the fingerprint differs from the last reported one
272
+ * → return the report (with `url`) and persist the new fingerprint.
273
+ * - Snippet has NO overrides BUT a non-empty fingerprint was stored → send the
274
+ * CLEAR report once (`{ snippet: { types: {} } }`, no master) so the server
275
+ * clears the flag, then clear the stored fingerprint.
276
+ * - Snippet has no overrides and nothing was previously reported → return null
277
+ * (send nothing — the common case).
278
+ * - Fingerprint unchanged → return null (already reported).
279
+ *
280
+ * The store is updated synchronously here (before the request resolves) so a
281
+ * second event in the same page load does not re-attach the report.
282
+ */
283
+ const resolveAcOverridesToReport = (params) => {
284
+ const { publicKey, report } = params;
285
+ if (!publicKey) return null;
286
+ try {
287
+ const fingerprint = computeOverrideFingerprint(report);
288
+ if (fingerprint === readReportedFingerprint(publicKey)) return null;
289
+ if (fingerprint === "") {
290
+ clearReportedFingerprint(publicKey);
291
+ return { snippet: { types: {} } };
292
+ }
293
+ writeReportedFingerprint(publicKey, fingerprint);
294
+ const out = { snippet: report };
295
+ if (params.url) out.url = params.url;
296
+ return out;
297
+ } catch (_unused5) {
298
+ return null;
299
+ }
300
+ };
301
+ //#endregion
302
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
303
+ function _typeof(o) {
304
+ "@babel/helpers - typeof";
305
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
306
+ return typeof o;
307
+ } : function(o) {
308
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
309
+ }, _typeof(o);
310
+ }
311
+ //#endregion
312
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
313
+ function toPrimitive(t, r) {
314
+ if ("object" != _typeof(t) || !t) return t;
315
+ var e = t[Symbol.toPrimitive];
316
+ if (void 0 !== e) {
317
+ var i = e.call(t, r || "default");
318
+ if ("object" != _typeof(i)) return i;
319
+ throw new TypeError("@@toPrimitive must return a primitive value.");
320
+ }
321
+ return ("string" === r ? String : Number)(t);
322
+ }
323
+ //#endregion
324
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
325
+ function toPropertyKey(t) {
326
+ var i = toPrimitive(t, "string");
327
+ return "symbol" == _typeof(i) ? i : i + "";
328
+ }
329
+ //#endregion
330
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
331
+ function _defineProperty(e, r, t) {
332
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
333
+ value: t,
334
+ enumerable: !0,
335
+ configurable: !0,
336
+ writable: !0
337
+ }) : e[r] = t, e;
338
+ }
339
+ //#endregion
340
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/objectSpread2.js
341
+ function ownKeys(e, r) {
342
+ var t = Object.keys(e);
343
+ if (Object.getOwnPropertySymbols) {
344
+ var o = Object.getOwnPropertySymbols(e);
345
+ r && (o = o.filter(function(r) {
346
+ return Object.getOwnPropertyDescriptor(e, r).enumerable;
347
+ })), t.push.apply(t, o);
348
+ }
349
+ return t;
350
+ }
351
+ function _objectSpread2(e) {
352
+ for (var r = 1; r < arguments.length; r++) {
353
+ var t = null != arguments[r] ? arguments[r] : {};
354
+ r % 2 ? ownKeys(Object(t), !0).forEach(function(r) {
355
+ _defineProperty(e, r, t[r]);
356
+ }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function(r) {
357
+ Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
358
+ });
359
+ }
360
+ return e;
361
+ }
362
+ //#endregion
363
+ //#region src/consent.ts
364
+ /**
365
+ * Consent store for the Leapio SDK.
366
+ *
367
+ * Holds the current consent snapshot ({categories, signals, source, updatedAt})
368
+ * and persists it through the same layered fallback chain used by
369
+ * `anonymous-id.ts`: cookie (`leapio_consent`) -> localStorage -> in-memory.
370
+ *
371
+ * The store is *source-priority aware*: an external consent source (CMP plugin,
372
+ * GCM reader, explicit Leapio API) writes a snapshot via `applySnapshot`, which
373
+ * keeps the highest-priority snapshot authoritative. This lets a later plugins
374
+ * agent register additional sources without re-architecting the store.
375
+ */
376
+ const CONSENT_KEY = "leapio_consent";
377
+ const LEGACY_CONSENT_KEY = "lgspt_consent";
378
+ /** Category -> single-letter code. */
379
+ const CATEGORY_TO_LETTER = {
380
+ analytics: "a",
381
+ functional: "f",
382
+ marketing: "m"
383
+ };
384
+ /** Canonical category order: a < f < m. */
385
+ const CATEGORY_ORDER = [
386
+ "analytics",
387
+ "functional",
388
+ "marketing"
389
+ ];
390
+ /** Signal -> single-letter code. */
391
+ const SIGNAL_TO_LETTER = [
392
+ {
393
+ key: "doNotSellShare",
394
+ letter: "d"
395
+ },
396
+ {
397
+ key: "gpc",
398
+ letter: "g"
399
+ },
400
+ {
401
+ key: "gppDetected",
402
+ letter: "p"
403
+ }
404
+ ];
405
+ /**
406
+ * Encode a set of granted categories into a canonical string (`a` < `f` < `m`).
407
+ * Categories whose value is not strictly `true` are omitted.
408
+ */
409
+ const encodeCategories = (categories) => {
410
+ let out = "";
411
+ for (const category of CATEGORY_ORDER) if (categories[category] === true) out += CATEGORY_TO_LETTER[category];
412
+ return out;
413
+ };
414
+ /**
415
+ * Encode privacy signals into a canonical string (`d` < `g` < `p`).
416
+ * Only signals whose value is strictly `true` are included; `gppString` is a
417
+ * raw value carried separately and never encoded into `privacy_signals`.
418
+ */
419
+ const encodeSignals = (signals) => {
420
+ let out = "";
421
+ for (const { key, letter } of SIGNAL_TO_LETTER) if (signals[key] === true) out += letter;
422
+ return out;
423
+ };
424
+ const SOURCE_PRIORITY = {
425
+ leapio_api: 100,
426
+ concord: 80,
427
+ onetrust: 60,
428
+ usercentrics: 60,
429
+ cookiebot: 60,
430
+ gcm: 40
431
+ };
432
+ /** Resolve a source's priority; unknown sources rank below all known ones. */
433
+ const consentSourcePriority = (source) => {
434
+ var _SOURCE_PRIORITY;
435
+ return (_SOURCE_PRIORITY = SOURCE_PRIORITY[source]) !== null && _SOURCE_PRIORITY !== void 0 ? _SOURCE_PRIORITY : 0;
436
+ };
437
+ const serialize = (snapshot) => {
438
+ try {
439
+ return JSON.stringify(snapshot);
440
+ } catch (_unused) {
441
+ return "";
442
+ }
443
+ };
444
+ const deserialize = (raw) => {
445
+ if (!raw) return null;
446
+ try {
447
+ const parsed = JSON.parse(raw);
448
+ if (!parsed || typeof parsed !== "object" || typeof parsed.source !== "string") return null;
449
+ return {
450
+ categories: parsed.categories && typeof parsed.categories === "object" ? parsed.categories : {},
451
+ signals: parsed.signals && typeof parsed.signals === "object" ? parsed.signals : {},
452
+ source: parsed.source.endsWith("_api") ? "leapio_api" : parsed.source,
453
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : (/* @__PURE__ */ new Date()).toISOString(),
454
+ sourceMetadata: parsed.sourceMetadata && typeof parsed.sourceMetadata === "object" ? parsed.sourceMetadata : void 0
455
+ };
456
+ } catch (_unused2) {
457
+ return null;
458
+ }
459
+ };
460
+ const tryReadCookie = (key) => {
461
+ try {
462
+ const raw = getCookie(key);
463
+ return deserialize(raw ? decodeURIComponent(raw) : null);
464
+ } catch (_unused3) {
465
+ return null;
466
+ }
467
+ };
468
+ const tryWriteCookie = (snapshot, opts) => {
469
+ try {
470
+ var _opts$cookieExpiratio, _opts$cookieDomain;
471
+ setCookie({
472
+ name: CONSENT_KEY,
473
+ value: encodeURIComponent(serialize(snapshot)),
474
+ expiresInSeconds: (_opts$cookieExpiratio = opts.cookieExpirationInSeconds) !== null && _opts$cookieExpiratio !== void 0 ? _opts$cookieExpiratio : ONE_YEAR_IN_SECONDS,
475
+ domain: (_opts$cookieDomain = opts.cookieDomain) !== null && _opts$cookieDomain !== void 0 ? _opts$cookieDomain : null
476
+ });
477
+ } catch (_unused4) {}
478
+ };
479
+ const tryReadLocalStorage = (key) => {
480
+ const ls = safeLocalStorage();
481
+ if (!ls) return null;
482
+ try {
483
+ return deserialize(ls.getItem(key));
484
+ } catch (_unused5) {
485
+ return null;
486
+ }
487
+ };
488
+ const tryWriteLocalStorage = (snapshot) => {
489
+ const ls = safeLocalStorage();
490
+ if (!ls) return;
491
+ try {
492
+ ls.setItem(CONSENT_KEY, serialize(snapshot));
493
+ } catch (_unused6) {}
494
+ };
495
+ let inMemorySnapshot = null;
496
+ /**
497
+ * Read the persisted snapshot: cookie -> localStorage -> in-memory.
498
+ * Honors `cookiesDisabled` exactly like `readAnonymousId`.
499
+ */
500
+ const readConsentSnapshot = (opts) => {
501
+ var _tryReadLocalStorage;
502
+ if (!opts.cookiesDisabled) {
503
+ var _tryReadCookie;
504
+ const fromCookie = (_tryReadCookie = tryReadCookie("leapio_consent")) !== null && _tryReadCookie !== void 0 ? _tryReadCookie : tryReadCookie(LEGACY_CONSENT_KEY);
505
+ if (fromCookie) return fromCookie;
506
+ }
507
+ const fromLs = (_tryReadLocalStorage = tryReadLocalStorage("leapio_consent")) !== null && _tryReadLocalStorage !== void 0 ? _tryReadLocalStorage : tryReadLocalStorage(LEGACY_CONSENT_KEY);
508
+ if (fromLs) return fromLs;
509
+ return inMemorySnapshot;
510
+ };
511
+ /**
512
+ * Persist a snapshot through the storage chain. Cookie first (unless disabled),
513
+ * then localStorage, then in-memory as the last-resort layer.
514
+ */
515
+ const writeConsentSnapshot = (snapshot, opts) => {
516
+ if (!opts.cookiesDisabled) tryWriteCookie(snapshot, opts);
517
+ tryWriteLocalStorage(snapshot);
518
+ inMemorySnapshot = snapshot;
519
+ };
520
+ /** Clear the consent snapshot from every storage layer. Used by reset(). */
521
+ const clearConsentSnapshot = (opts) => {
522
+ for (const name of [CONSENT_KEY, LEGACY_CONSENT_KEY]) try {
523
+ var _opts$cookieDomain2;
524
+ eraseCookie(name, (_opts$cookieDomain2 = opts.cookieDomain) !== null && _opts$cookieDomain2 !== void 0 ? _opts$cookieDomain2 : null);
525
+ } catch (_unused7) {}
526
+ const ls = safeLocalStorage();
527
+ if (ls) try {
528
+ ls.removeItem(CONSENT_KEY);
529
+ ls.removeItem(LEGACY_CONSENT_KEY);
530
+ } catch (_unused8) {}
531
+ inMemorySnapshot = null;
532
+ };
533
+ /**
534
+ * Stateful wrapper around the persisted snapshot. The SDK keeps one instance.
535
+ *
536
+ * `applySnapshot(snapshot)` is the clean internal API external sources write
537
+ * through: it enforces source priority (a lower-priority source never replaces
538
+ * a higher-priority snapshot) and persists + returns whether it became
539
+ * authoritative. The consent-source registry writes through this API.
540
+ */
541
+ var ConsentStore = class {
542
+ constructor(opts) {
543
+ this.opts = opts;
544
+ this.current = readConsentSnapshot(opts);
545
+ }
546
+ /** The current authoritative snapshot, or null if no source has spoken. */
547
+ get() {
548
+ return this.current;
549
+ }
550
+ /** True once any source has produced a snapshot. */
551
+ hasSnapshot() {
552
+ return this.current !== null;
553
+ }
554
+ /**
555
+ * Apply a snapshot from a consent source. Returns `true` if it became (or
556
+ * remained) the authoritative snapshot and was persisted, `false` if it was
557
+ * rejected because a strictly higher-priority source is already authoritative.
558
+ *
559
+ * Ties (same source priority, e.g. a fresh read from the same source) always
560
+ * win so updates from the authoritative source are not dropped.
561
+ */
562
+ applySnapshot(snapshot) {
563
+ if (this.current) {
564
+ if (consentSourcePriority(snapshot.source) < consentSourcePriority(this.current.source)) return false;
565
+ }
566
+ this.current = snapshot;
567
+ writeConsentSnapshot(snapshot, this.opts);
568
+ return true;
569
+ }
570
+ /**
571
+ * Merge a partial update into the snapshot from a given source. Used by
572
+ * `setConsent` (source `leapio_api`): unspecified categories/signals are
573
+ * preserved from the existing snapshot. Subject to the same priority rule.
574
+ */
575
+ mergeSnapshot(partial, source) {
576
+ var _base$categories, _partial$categories, _base$signals, _partial$signals, _partial$sourceMetada;
577
+ const base = this.current;
578
+ const merged = {
579
+ categories: _objectSpread2(_objectSpread2({}, (_base$categories = base === null || base === void 0 ? void 0 : base.categories) !== null && _base$categories !== void 0 ? _base$categories : {}), (_partial$categories = partial.categories) !== null && _partial$categories !== void 0 ? _partial$categories : {}),
580
+ signals: _objectSpread2(_objectSpread2({}, (_base$signals = base === null || base === void 0 ? void 0 : base.signals) !== null && _base$signals !== void 0 ? _base$signals : {}), (_partial$signals = partial.signals) !== null && _partial$signals !== void 0 ? _partial$signals : {}),
581
+ source,
582
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
583
+ sourceMetadata: (_partial$sourceMetada = partial.sourceMetadata) !== null && _partial$sourceMetada !== void 0 ? _partial$sourceMetada : base === null || base === void 0 ? void 0 : base.sourceMetadata
584
+ };
585
+ return this.applySnapshot(merged);
586
+ }
587
+ /** Clear all consent state (every storage layer). Used by reset(). */
588
+ clear() {
589
+ clearConsentSnapshot(this.opts);
590
+ this.current = null;
591
+ }
592
+ };
593
+ //#endregion
594
+ //#region src/remote-config.ts
595
+ /** localStorage key prefix; the public key is appended for per-project caching. */
596
+ const REMOTE_CONFIG_KEY_PREFIX = "leapio_cfg_";
597
+ const keyFor = (publicKey) => `${REMOTE_CONFIG_KEY_PREFIX}${publicKey}`;
598
+ /** Narrow an unknown parsed value to a cache envelope, or null. */
599
+ const asCachedConfig = (value) => {
600
+ if (typeof value !== "object" || value === null) return null;
601
+ const candidate = value;
602
+ if (typeof candidate.cv !== "number" || typeof candidate.ts !== "number") return null;
603
+ if (typeof candidate.config !== "object" || candidate.config === null) return null;
604
+ return candidate;
605
+ };
606
+ /**
607
+ * Read the cached config for a public key. Returns null when there is nothing
608
+ * cached, when the cache format / TTL no longer matches, or when anything at
609
+ * all goes wrong (corrupt JSON, unavailable storage). Never throws.
610
+ */
611
+ const readRemoteConfig = (publicKey) => {
612
+ if (!publicKey) return null;
613
+ const ls = safeLocalStorage();
614
+ if (!ls) return null;
615
+ let raw;
616
+ try {
617
+ raw = ls.getItem(keyFor(publicKey));
618
+ } catch (_unused) {
619
+ return null;
620
+ }
621
+ if (!raw) return null;
622
+ let parsed;
623
+ try {
624
+ parsed = JSON.parse(raw);
625
+ } catch (_unused2) {
626
+ return null;
627
+ }
628
+ const cached = asCachedConfig(parsed);
629
+ if (!cached) return null;
630
+ if (cached.cv !== 1) return null;
631
+ if (Date.now() - cached.ts > 864e5) return null;
632
+ return cached.config;
633
+ };
634
+ /**
635
+ * Persist the config for a public key. Best-effort: silently no-ops when
636
+ * storage is unavailable or the write fails. Never throws.
637
+ */
638
+ const writeRemoteConfig = (publicKey, config) => {
639
+ if (!publicKey || typeof config !== "object" || config === null) return;
640
+ const ls = safeLocalStorage();
641
+ if (!ls) return;
642
+ const envelope = {
643
+ cv: 1,
644
+ ts: Date.now(),
645
+ config
646
+ };
647
+ try {
648
+ ls.setItem(keyFor(publicKey), JSON.stringify(envelope));
649
+ } catch (_unused3) {}
650
+ };
651
+ //#endregion
652
+ //#region src/url-privacy.ts
653
+ const DEFAULT_QUERY_PARAM_ALLOWLIST = [
654
+ "utm_source",
655
+ "utm_id",
656
+ "utm_medium",
657
+ "utm_campaign",
658
+ "utm_term",
659
+ "utm_content",
660
+ "gclid",
661
+ "gbraid",
662
+ "wbraid",
663
+ "fbclid",
664
+ "msclkid",
665
+ "ttclid",
666
+ "ref"
667
+ ];
668
+ const hasScheme = (value) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
669
+ /**
670
+ * Remove non-allow-listed query params (and always the hash) from `rawUrl`.
671
+ * - Matching is case-insensitive on the param NAME; kept params preserve their
672
+ * original order, repeats, and values.
673
+ * - Relative URLs are supported (the sentinel base is stripped back off).
674
+ * - Never throws and never returns a broken url: on parse failure it returns the
675
+ * original string (the server still strips authoritatively).
676
+ */
677
+ const stripUrl = (rawUrl, allowlist = DEFAULT_QUERY_PARAM_ALLOWLIST) => {
678
+ if (!rawUrl) return rawUrl;
679
+ try {
680
+ const allow = new Set(allowlist.map((name) => name.toLowerCase()));
681
+ const absolute = hasScheme(rawUrl);
682
+ const parsed = new URL(rawUrl, absolute ? void 0 : "http://x.invalid");
683
+ const kept = new URLSearchParams();
684
+ parsed.searchParams.forEach((value, key) => {
685
+ if (allow.has(key.toLowerCase())) kept.append(key, value);
686
+ });
687
+ const search = kept.toString();
688
+ const path = `${parsed.pathname}${search ? `?${search}` : ""}`;
689
+ return absolute ? `${parsed.protocol}//${parsed.host}${path}` : path;
690
+ } catch (_unused) {
691
+ return rawUrl;
692
+ }
693
+ };
694
+ //#endregion
695
+ //#region src/api.ts
696
+ const API_URL = "https://api.leapio.com";
697
+ const deriveEndpoint = (config, suffix) => {
698
+ if (!config.externalApiUrl) return `${API_URL}/${suffix}`;
699
+ try {
700
+ const url = new URL(config.externalApiUrl);
701
+ if (/\/(i|track)\/?$/.test(url.pathname)) {
702
+ url.pathname = url.pathname.replace(/\/(i|track)\/?$/, `/${suffix}`);
703
+ return url.toString();
704
+ }
705
+ } catch (_unused) {}
706
+ console.error(`Leapio - cannot derive the /${suffix} endpoint from externalApiUrl. Set ${suffix === "identify" ? "identifyApiUrl" : "consentApiUrl"} explicitly.`);
707
+ return null;
708
+ };
709
+ const identifyUrlFromConfig = (config) => {
710
+ if (config.identifyApiUrl) return config.identifyApiUrl;
711
+ return deriveEndpoint(config, "identify");
712
+ };
713
+ const consentUrlFromConfig = (config) => {
714
+ if (config.consentApiUrl) return config.consentApiUrl;
715
+ return deriveEndpoint(config, "consent");
716
+ };
717
+ const groupUrlFromConfig = (config) => {
718
+ if (config.groupApiUrl) return config.groupApiUrl;
719
+ return deriveEndpoint(config, "group");
720
+ };
721
+ /**
722
+ * Apply consent stamps to an event payload. Called *after* the user's
723
+ * `eventMapper` so the stamps are authoritative and never clobbered. When no
724
+ * snapshot exists the stamps default to empty strings ("pre-consent / unknown"),
725
+ * matching the server's legacy-empty handling.
726
+ */
727
+ const consentStampFields = (snapshot) => snapshot ? {
728
+ privacy_categories: encodeCategories(snapshot.categories),
729
+ privacy_signals: encodeSignals(snapshot.signals),
730
+ consent_source: snapshot.source
731
+ } : {
732
+ privacy_categories: "",
733
+ privacy_signals: "",
734
+ consent_source: null
735
+ };
736
+ const stampConsent = (payload, snapshot) => _objectSpread2(_objectSpread2({}, payload), consentStampFields(snapshot));
737
+ const trackEvent = async (config, payload, consentSnapshot, acOverrides) => {
738
+ if (!payload.event) {
739
+ console.error("Leapio - event parameter is required");
740
+ return;
741
+ }
742
+ try {
743
+ var _payload$userId, _payload$anonymousId, _payload$metadata, _payload$referrer;
744
+ let payloadToSend = _objectSpread2(_objectSpread2(_objectSpread2(_objectSpread2({
745
+ name: payload.event,
746
+ channel: payload.channel,
747
+ message: payload.message,
748
+ notify: payload.notify,
749
+ user_id: (_payload$userId = payload.userId) !== null && _payload$userId !== void 0 ? _payload$userId : null,
750
+ anonymous_id: (_payload$anonymousId = payload.anonymousId) !== null && _payload$anonymousId !== void 0 ? _payload$anonymousId : null,
751
+ metadata: (_payload$metadata = payload.metadata) !== null && _payload$metadata !== void 0 ? _payload$metadata : {},
752
+ hostname: payload.hostname,
753
+ url: payload.url,
754
+ referrer: ((_payload$referrer = payload.referrer) === null || _payload$referrer === void 0 ? void 0 : _payload$referrer.length) ? payload.referrer : null,
755
+ language: payload.language,
756
+ screen: payload.screen
757
+ }, payload.value != null ? { value: payload.value } : {}), payload.currency != null ? { currency: payload.currency } : {}), payload.groupId ? { group_id: payload.groupId } : {}), payload.groupId ? { group_type: payload.groupType || "company" } : {});
758
+ if (config.eventMapper) payloadToSend = await config.eventMapper(payloadToSend);
759
+ if (config.stripQueryParams) {
760
+ var _payloadToSend$referr;
761
+ payloadToSend = _objectSpread2(_objectSpread2({}, payloadToSend), {}, {
762
+ url: stripUrl(payloadToSend.url, config.queryParamAllowlist),
763
+ referrer: ((_payloadToSend$referr = payloadToSend.referrer) === null || _payloadToSend$referr === void 0 ? void 0 : _payloadToSend$referr.length) ? stripUrl(payloadToSend.referrer, config.queryParamAllowlist) : null
764
+ });
765
+ }
766
+ if (payloadToSend.referrer && payloadToSend.referrer.length > 255) payloadToSend = _objectSpread2(_objectSpread2({}, payloadToSend), {}, { referrer: payloadToSend.referrer.slice(0, 255) });
767
+ payloadToSend = stampConsent(payloadToSend, consentSnapshot !== null && consentSnapshot !== void 0 ? consentSnapshot : null);
768
+ const wireBody = acOverrides ? _objectSpread2(_objectSpread2({}, payloadToSend), {}, { _acOverrides: acOverrides }) : payloadToSend;
769
+ const response = await fetch(config.externalApiUrl ? config.externalApiUrl : `${API_URL}/i`, {
770
+ method: "POST",
771
+ headers: {
772
+ "Content-Type": "application/json",
773
+ "x-leapio-pk": config.publicKey
774
+ },
775
+ mode: "cors",
776
+ keepalive: true,
777
+ body: JSON.stringify(wireBody)
778
+ });
779
+ if (!response.ok) {
780
+ console.error("Leapio - track rejected", response.status);
781
+ return;
782
+ }
783
+ await persistConfigFromResponse(config.publicKey, response);
784
+ } catch (err) {
785
+ console.error("Leapio - could not track event");
786
+ }
787
+ };
788
+ /**
789
+ * Read the `config` field from a track response and cache it for the public
790
+ * key. Tolerates non-JSON / missing bodies and never throws — config delivery
791
+ * is additive and must never break tracking.
792
+ */
793
+ const persistConfigFromResponse = async (publicKey, response) => {
794
+ if (!publicKey || !response || !response.ok) return;
795
+ try {
796
+ const body = await response.json();
797
+ if (typeof body !== "object" || body === null) return;
798
+ const config = body.config;
799
+ if (typeof config === "object" && config !== null) writeRemoteConfig(publicKey, config);
800
+ } catch (_unused2) {}
801
+ };
802
+ const identifyUser = async (config, payload, consentSnapshot) => {
803
+ var _payload$traits, _payload$options;
804
+ if (!payload.userId) {
805
+ console.error("Leapio - identify requires a userId");
806
+ return {
807
+ ok: false,
808
+ reason: "missing_user_id"
809
+ };
810
+ }
811
+ const body = {
812
+ anonymous_id: payload.anonymousId,
813
+ user_id: payload.userId,
814
+ traits: (_payload$traits = payload.traits) !== null && _payload$traits !== void 0 ? _payload$traits : {},
815
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
816
+ };
817
+ Object.assign(body, consentStampFields(consentSnapshot !== null && consentSnapshot !== void 0 ? consentSnapshot : null));
818
+ if ((_payload$options = payload.options) === null || _payload$options === void 0 || (_payload$options = _payload$options.identityVerification) === null || _payload$options === void 0 ? void 0 : _payload$options.token) body.identity_verification = { token: payload.options.identityVerification.token };
819
+ const endpoint = identifyUrlFromConfig(config);
820
+ if (!endpoint) return {
821
+ ok: false,
822
+ reason: "no_endpoint"
823
+ };
824
+ try {
825
+ const response = await fetch(endpoint, {
826
+ method: "POST",
827
+ headers: {
828
+ "Content-Type": "application/json",
829
+ "x-leapio-pk": config.publicKey
830
+ },
831
+ mode: "cors",
832
+ keepalive: true,
833
+ body: JSON.stringify(body)
834
+ });
835
+ if (response.ok) return {
836
+ ok: true,
837
+ status: response.status
838
+ };
839
+ let reason;
840
+ try {
841
+ const errorBody = await response.json();
842
+ reason = typeof (errorBody === null || errorBody === void 0 ? void 0 : errorBody.message) === "string" ? errorBody.message : void 0;
843
+ } catch (_unused3) {}
844
+ console.error("Leapio - identify failed", response.status, reason);
845
+ return {
846
+ ok: false,
847
+ status: response.status,
848
+ reason
849
+ };
850
+ } catch (err) {
851
+ console.error("Leapio - could not identify user");
852
+ return {
853
+ ok: false,
854
+ reason: "network_error"
855
+ };
856
+ }
857
+ };
858
+ /**
859
+ * Associate the current user/anonymous visitor with a group (Segment `group()`).
860
+ * Sent with the `x-leapio-pk` header like the other public-API calls. The
861
+ * platform requires a `user_id` or `anonymous_id`; the SDK always supplies its
862
+ * managed `anonymous_id`, and `user_id` when the visitor is identified.
863
+ */
864
+ const groupUser = async (config, payload) => {
865
+ var _payload$options2;
866
+ if (!payload.groupId) {
867
+ console.error("Leapio - group requires a groupId");
868
+ return {
869
+ ok: false,
870
+ reason: "missing_group_id"
871
+ };
872
+ }
873
+ if (!payload.userId && !payload.anonymousId) {
874
+ console.error("Leapio - group requires a userId or anonymousId");
875
+ return {
876
+ ok: false,
877
+ reason: "missing_subject"
878
+ };
879
+ }
880
+ const body = {
881
+ group_id: payload.groupId,
882
+ type: payload.type || "company",
883
+ anonymous_id: payload.anonymousId,
884
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
885
+ };
886
+ if (payload.userId) body.user_id = payload.userId;
887
+ if (payload.traits) body.traits = payload.traits;
888
+ if ((_payload$options2 = payload.options) === null || _payload$options2 === void 0 || (_payload$options2 = _payload$options2.identityVerification) === null || _payload$options2 === void 0 ? void 0 : _payload$options2.token) body.identity_verification = { token: payload.options.identityVerification.token };
889
+ const endpoint = groupUrlFromConfig(config);
890
+ if (!endpoint) return {
891
+ ok: false,
892
+ reason: "no_endpoint"
893
+ };
894
+ try {
895
+ const response = await fetch(endpoint, {
896
+ method: "POST",
897
+ headers: {
898
+ "Content-Type": "application/json",
899
+ "x-leapio-pk": config.publicKey
900
+ },
901
+ mode: "cors",
902
+ keepalive: true,
903
+ body: JSON.stringify(body)
904
+ });
905
+ if (response.ok) return {
906
+ ok: true,
907
+ status: response.status
908
+ };
909
+ let reason;
910
+ try {
911
+ const errorBody = await response.json();
912
+ reason = typeof (errorBody === null || errorBody === void 0 ? void 0 : errorBody.message) === "string" ? errorBody.message : void 0;
913
+ } catch (_unused4) {}
914
+ console.error("Leapio - group failed", response.status, reason);
915
+ return {
916
+ ok: false,
917
+ status: response.status,
918
+ reason
919
+ };
920
+ } catch (err) {
921
+ console.error("Leapio - could not associate group");
922
+ return {
923
+ ok: false,
924
+ reason: "network_error"
925
+ };
926
+ }
927
+ };
928
+ /**
929
+ * POST the current consent state to the backend `/consent` endpoint.
930
+ *
931
+ * Body shape:
932
+ * { anonymous_id?, user_id?, analytics?, functional?, marketing?,
933
+ * doNotSellShare?, gpc?, gppDetected?, gppString?, source, sourceMetadata? }
934
+ *
935
+ * Sent with the `x-leapio-pk` header like the other public-API calls. Only
936
+ * fields that are present in the snapshot are included so the server can tell
937
+ * "not specified" apart from an explicit `false`.
938
+ */
939
+ const postConsent = async (config, payload) => {
940
+ var _payload$identityVeri;
941
+ const { categories, signals, source, sourceMetadata } = payload.snapshot;
942
+ const body = { source };
943
+ if (payload.anonymousId) body.anonymous_id = payload.anonymousId;
944
+ if (payload.userId) body.user_id = payload.userId;
945
+ if (typeof categories.analytics === "boolean") body.analytics = categories.analytics;
946
+ if (typeof categories.functional === "boolean") body.functional = categories.functional;
947
+ if (typeof categories.marketing === "boolean") body.marketing = categories.marketing;
948
+ if (typeof signals.doNotSellShare === "boolean") body.doNotSellShare = signals.doNotSellShare;
949
+ if (typeof signals.gpc === "boolean") body.gpc = signals.gpc;
950
+ if (typeof signals.gppDetected === "boolean") body.gppDetected = signals.gppDetected;
951
+ if (typeof signals.gppString === "string") body.gppString = signals.gppString;
952
+ if (sourceMetadata) body.sourceMetadata = sourceMetadata;
953
+ if ((_payload$identityVeri = payload.identityVerification) === null || _payload$identityVeri === void 0 ? void 0 : _payload$identityVeri.token) body.identity_verification = { token: payload.identityVerification.token };
954
+ const endpoint = consentUrlFromConfig(config);
955
+ if (!endpoint) return {
956
+ ok: false,
957
+ reason: "no_endpoint"
958
+ };
959
+ try {
960
+ const response = await fetch(endpoint, {
961
+ method: "POST",
962
+ headers: {
963
+ "Content-Type": "application/json",
964
+ "x-leapio-pk": config.publicKey
965
+ },
966
+ mode: "cors",
967
+ keepalive: true,
968
+ body: JSON.stringify(body)
969
+ });
970
+ if (response.ok) return {
971
+ ok: true,
972
+ status: response.status
973
+ };
974
+ let reason;
975
+ try {
976
+ const errorBody = await response.json();
977
+ reason = typeof (errorBody === null || errorBody === void 0 ? void 0 : errorBody.message) === "string" ? errorBody.message : void 0;
978
+ } catch (_unused5) {}
979
+ console.error("Leapio - consent push failed", response.status, reason);
980
+ return {
981
+ ok: false,
982
+ status: response.status,
983
+ reason
984
+ };
985
+ } catch (err) {
986
+ console.error("Leapio - could not push consent");
987
+ return {
988
+ ok: false,
989
+ reason: "network_error"
990
+ };
991
+ }
992
+ };
993
+ //#endregion
994
+ //#region src/utils.ts
995
+ const removeQueryParamsFromUrl = (url, params) => {
996
+ try {
997
+ const hashIndex = url.indexOf("#");
998
+ const hash = hashIndex >= 0 ? url.slice(hashIndex) : "";
999
+ const [base, queryString] = (hashIndex >= 0 ? url.slice(0, hashIndex) : url).split("?");
1000
+ if (!queryString) return `${base}${hash}`;
1001
+ const cleaned = Array.from(new URLSearchParams(queryString).entries()).filter(([param]) => !params.includes(param));
1002
+ if (!cleaned.length) return `${base}${hash}`;
1003
+ const queryParams = new URLSearchParams();
1004
+ for (const [param, value] of cleaned) queryParams.append(param, value);
1005
+ return `${base}?${queryParams}${hash}`;
1006
+ } catch (err) {
1007
+ return url;
1008
+ }
1009
+ };
1010
+ const b64EncodeUnicode = (str) => {
1011
+ return btoa(encodeURIComponent(str));
1012
+ };
1013
+ const UnicodeDecodeB64 = (str) => {
1014
+ return decodeURIComponent(atob(str));
1015
+ };
1016
+ //#endregion
1017
+ //#region src/campaign.ts
1018
+ const CAMPAIGN_KEYWORDS = [
1019
+ "gclid",
1020
+ "fbclid",
1021
+ "msclkid",
1022
+ "utm_source",
1023
+ "utm_id",
1024
+ "utm_medium",
1025
+ "utm_campaign",
1026
+ "utm_content",
1027
+ "utm_term"
1028
+ ];
1029
+ const LINK_TAGS = [...CAMPAIGN_KEYWORDS, "ref"];
1030
+ const CAMPAIGN_COOKIE = "leapio_c";
1031
+ const LEGACY_CAMPAIGN_COOKIE = "lgspt_c";
1032
+ const LEGACY_FIRST_TOUCH_COOKIE = "lgspt_ft";
1033
+ const FIRST_TOUCH_COOKIE = "leapio_ft";
1034
+ const DEFAULT_EXPIRATION = 604800;
1035
+ const FIRST_TOUCH_EXPIRATION = 31536e3;
1036
+ var Campaign = class {
1037
+ constructor(sdkConfig) {
1038
+ this.sdkConfig = sdkConfig;
1039
+ const params = this._hasLinkTag() ? this._getParams() : this._getCampaignCookie();
1040
+ if (sdkConfig.stickyCampaigns) this._setCampaignCookie(params);
1041
+ this.campaignParams = params;
1042
+ this._captureFirstTouch();
1043
+ }
1044
+ getCampaignParams() {
1045
+ const params = this._getParams();
1046
+ if (this.sdkConfig.stickyCampaigns && !this._hasLinkTag()) return this.campaignParams;
1047
+ return params;
1048
+ }
1049
+ /**
1050
+ * The durable first-touch params captured on the visitor's first campaign
1051
+ * visit (UTM/click-ids + referrer + landing url). Empty object when no
1052
+ * first-touch has been recorded (pure direct traffic so far). Read-only — the
1053
+ * cookie is never overwritten once set.
1054
+ */
1055
+ getFirstTouchParams() {
1056
+ return this._getFirstTouchCookie();
1057
+ }
1058
+ _getCampaignCookie() {
1059
+ try {
1060
+ var _getCookie;
1061
+ const cookie = (_getCookie = getCookie(CAMPAIGN_COOKIE)) !== null && _getCookie !== void 0 ? _getCookie : getCookie(LEGACY_CAMPAIGN_COOKIE);
1062
+ return cookie ? JSON.parse(UnicodeDecodeB64(cookie)) : {};
1063
+ } catch (_unused) {
1064
+ return {};
1065
+ }
1066
+ }
1067
+ _setCampaignCookie(campaignParams) {
1068
+ var _this$sdkConfig$stick, _this$sdkConfig$cooki;
1069
+ setCookie({
1070
+ name: CAMPAIGN_COOKIE,
1071
+ value: b64EncodeUnicode(JSON.stringify(campaignParams)),
1072
+ expiresInSeconds: (_this$sdkConfig$stick = this.sdkConfig.stickyCampaignsExpirationInSeconds) !== null && _this$sdkConfig$stick !== void 0 ? _this$sdkConfig$stick : DEFAULT_EXPIRATION,
1073
+ domain: (_this$sdkConfig$cooki = this.sdkConfig.cookieDomain) !== null && _this$sdkConfig$cooki !== void 0 ? _this$sdkConfig$cooki : null
1074
+ });
1075
+ }
1076
+ _hasLinkTag() {
1077
+ const query = new URLSearchParams(window.location.search);
1078
+ return LINK_TAGS.some((key) => Boolean(query.get(key)));
1079
+ }
1080
+ _getParams() {
1081
+ const urlSearchParams = new URLSearchParams(window.location.search);
1082
+ const queryParams = Object.fromEntries(urlSearchParams.entries());
1083
+ return CAMPAIGN_KEYWORDS.reduce((acc, next) => {
1084
+ var _queryParams$next;
1085
+ if ((_queryParams$next = queryParams[next]) === null || _queryParams$next === void 0 ? void 0 : _queryParams$next.length) return _objectSpread2(_objectSpread2({}, acc), {}, { [next]: queryParams[next] });
1086
+ return acc;
1087
+ }, {});
1088
+ }
1089
+ _getFirstTouchCookie() {
1090
+ try {
1091
+ var _getCookie2;
1092
+ const cookie = (_getCookie2 = getCookie(FIRST_TOUCH_COOKIE)) !== null && _getCookie2 !== void 0 ? _getCookie2 : getCookie(LEGACY_FIRST_TOUCH_COOKIE);
1093
+ return cookie ? JSON.parse(UnicodeDecodeB64(cookie)) : {};
1094
+ } catch (_unused2) {
1095
+ return {};
1096
+ }
1097
+ }
1098
+ /**
1099
+ * Persist the first-touch snapshot exactly once. A first-touch is recorded
1100
+ * only when the visit carries a campaign signal (any UTM/click-id) or a
1101
+ * referrer — pure direct first visits do not lock in an empty first-touch, so
1102
+ * a later campaign visit can still become the first-touch.
1103
+ */
1104
+ _captureFirstTouch() {
1105
+ var _this$sdkConfig$cooki2;
1106
+ const existing = this._getFirstTouchCookie();
1107
+ if (Object.keys(existing).length > 0) return;
1108
+ const params = this._getParams();
1109
+ const rawReferrer = typeof document !== "undefined" ? document.referrer || "" : "";
1110
+ const referrer = this._isExternalReferrer(rawReferrer) ? rawReferrer : "";
1111
+ const landingUrl = typeof window !== "undefined" && window.location ? window.location.href : "";
1112
+ const hasCampaignSignal = Object.keys(params).length > 0;
1113
+ const hasReferrer = referrer.length > 0;
1114
+ if (!hasCampaignSignal && !hasReferrer) return;
1115
+ const firstTouch = _objectSpread2({}, params);
1116
+ if (hasReferrer) firstTouch.referrer = referrer;
1117
+ if (landingUrl) firstTouch.landing_url = landingUrl;
1118
+ setCookie({
1119
+ name: FIRST_TOUCH_COOKIE,
1120
+ value: b64EncodeUnicode(JSON.stringify(firstTouch)),
1121
+ expiresInSeconds: FIRST_TOUCH_EXPIRATION,
1122
+ domain: (_this$sdkConfig$cooki2 = this.sdkConfig.cookieDomain) !== null && _this$sdkConfig$cooki2 !== void 0 ? _this$sdkConfig$cooki2 : null
1123
+ });
1124
+ }
1125
+ _isExternalReferrer(referrer) {
1126
+ if (!referrer) return false;
1127
+ try {
1128
+ return new URL(referrer).host !== window.location.host;
1129
+ } catch (_unused3) {
1130
+ return true;
1131
+ }
1132
+ }
1133
+ };
1134
+ //#endregion
1135
+ //#region src/class-listener.ts
1136
+ const LEGACY_CLASS_PREFIX = "lgspt-";
1137
+ /**
1138
+ * Attach the always-on `lgspt-` classname click listener. Returns a teardown
1139
+ * that removes the listener (invoked on `reset()`); the listener semantics are
1140
+ * otherwise unchanged from the historical always-on behavior.
1141
+ */
1142
+ const listenToClassClicks = (trackClick) => {
1143
+ const onClick = (event) => {
1144
+ const target = event.target;
1145
+ if (new RegExp(LEGACY_CLASS_PREFIX).test(target.className) === false) return;
1146
+ const classList = target.className.split(" ").filter((className) => className.startsWith(LEGACY_CLASS_PREFIX));
1147
+ if (classList.length === 0) return;
1148
+ trackClick({ name: classList[0].replace(LEGACY_CLASS_PREFIX, "").split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ") });
1149
+ };
1150
+ document.addEventListener("click", onClick);
1151
+ return () => document.removeEventListener("click", onClick);
1152
+ };
1153
+ //#endregion
1154
+ //#region src/consent-sources/listen.ts
1155
+ const listenToWindowEvents = (events, onEvent) => {
1156
+ const attached = [];
1157
+ for (const eventName of events) try {
1158
+ window.addEventListener(eventName, onEvent);
1159
+ attached.push(() => window.removeEventListener(eventName, onEvent));
1160
+ } catch (_unused) {}
1161
+ return () => {
1162
+ for (const off of attached) try {
1163
+ off();
1164
+ } catch (_unused2) {}
1165
+ };
1166
+ };
1167
+ //#endregion
1168
+ //#region src/consent-sources/concord.ts
1169
+ /**
1170
+ * Concord CMP adapter (concord.tech). Read-only. Sits high on the source
1171
+ * ladder (`concord`, just below the explicit API).
1172
+ *
1173
+ * Contract per the public docs:
1174
+ * https://www.concord.tech/docs/manually-blocking-trackers-before-consent
1175
+ *
1176
+ * window.concord.consent.hasConsent(category: string): boolean
1177
+ * — reads `store.consentState.allowState[category] === true`. Definitive
1178
+ * under Concord's enforcement model: `false` means "not granted (yet)",
1179
+ * which is exactly what Concord itself uses to gate trackers, so we
1180
+ * mirror it verbatim.
1181
+ * window.concord.consent.hasDoNotSell(): boolean
1182
+ * — `allowState.do_not_sell === true`. The adapter reports this signal
1183
+ * FAITHFULLY whenever it is active — signals and categories are
1184
+ * independent facts and the adapter is a reader, not an interpreter.
1185
+ * SEMANTICS NOTE: `marketing: true` +
1186
+ * `do_not_sell: true` almost always means DNS/GPC was detected and
1187
+ * auto-declined marketing, and the user then manually flipped
1188
+ * marketing back on. Either way the CMP result is trusted AS-IS — how
1189
+ * marketing got to true is Concord's business. The resolution happens
1190
+ * DOWNSTREAM in Leapio's export evaluation (a source-reported
1191
+ * `marketing: true` in current consent overrides `doNotSellShare`;
1192
+ * DNS wins only when no source ever spoke for marketing), so both
1193
+ * facts must arrive intact.
1194
+ * window.concord.consent.hasGPC(): boolean
1195
+ * — true when the `global_privacy_control` consent record is accepted
1196
+ * (i.e. Concord detected and applied GPC). Feeds Leapio's `gpc`
1197
+ * signal.
1198
+ * window.concord.consent.isDefaults: boolean (proxy getter)
1199
+ * — true while the state is region-template defaults (no real user
1200
+ * decision yet). Recorded as sourceMetadata so Leapio can tell
1201
+ * default-derived consent from an actual user decision.
1202
+ * window.concord.consent.onConsent(category, cb)
1203
+ * — one-shot grant hook; we rely on the window events below instead,
1204
+ * which also cover revocations.
1205
+ *
1206
+ * Window events:
1207
+ * concord-ready — fully initialized
1208
+ * concord-consent-state-loading — fetching consent state (reads unsafe)
1209
+ * concord-consent-state-loaded — consent state retrieved
1210
+ * concord-consent-state-changed — user updated preferences
1211
+ * concord-unavailable — Concord aborted (it also sets
1212
+ * window.concord = undefined)
1213
+ *
1214
+ * Category mapping is IDENTITY: Concord's canonical categories are already
1215
+ * `analytics` / `functional` / `marketing` — `hasConsent` is called with our
1216
+ * category names directly.
1217
+ *
1218
+ * The signal helpers (`hasDoNotSell`/`hasGPC`) and `isDefaults` are guarded as
1219
+ * optional — older Concord builds that only ship `hasConsent` still work,
1220
+ * they just emit categories only.
1221
+ */
1222
+ const SOURCE_NAME$4 = "concord";
1223
+ const PRIORITY$4 = 80;
1224
+ const CATEGORIES = [
1225
+ "analytics",
1226
+ "functional",
1227
+ "marketing"
1228
+ ];
1229
+ const getConcordConsentApi = () => {
1230
+ try {
1231
+ var _win$concord;
1232
+ const consent = (_win$concord = window.concord) === null || _win$concord === void 0 ? void 0 : _win$concord.consent;
1233
+ if (consent && typeof consent === "object" && typeof consent.hasConsent === "function") return consent;
1234
+ return null;
1235
+ } catch (_unused) {
1236
+ return null;
1237
+ }
1238
+ };
1239
+ const STATE_READY_EVENTS = [
1240
+ "concord-ready",
1241
+ "concord-consent-state-loaded",
1242
+ "concord-consent-state-changed"
1243
+ ];
1244
+ /**
1245
+ * Construct the Concord CMP source. `read()` yields `null` until
1246
+ * `window.concord.consent` is present (and not mid-load / aborted — see the
1247
+ * loading/unavailable guards, which only engage once `subscribe()` has
1248
+ * observed the corresponding events).
1249
+ */
1250
+ const createConcordSource = () => {
1251
+ let stateLoading = false;
1252
+ let unavailable = false;
1253
+ return {
1254
+ name: SOURCE_NAME$4,
1255
+ priority: PRIORITY$4,
1256
+ read() {
1257
+ try {
1258
+ if (unavailable || stateLoading) return null;
1259
+ const api = getConcordConsentApi();
1260
+ if (!api) return null;
1261
+ const categories = {};
1262
+ for (const category of CATEGORIES) categories[category] = api.hasConsent(category) === true;
1263
+ const signals = {};
1264
+ const sourceMetadata = {};
1265
+ if (typeof api.hasDoNotSell === "function" && api.hasDoNotSell()) signals.doNotSellShare = true;
1266
+ if (typeof api.hasGPC === "function" && api.hasGPC() === true) signals.gpc = true;
1267
+ if (typeof api.isDefaults === "boolean") sourceMetadata.isDefaults = api.isDefaults;
1268
+ const snapshot = {
1269
+ categories,
1270
+ signals,
1271
+ source: SOURCE_NAME$4,
1272
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1273
+ };
1274
+ if (Object.keys(sourceMetadata).length > 0) snapshot.sourceMetadata = sourceMetadata;
1275
+ return snapshot;
1276
+ } catch (_unused2) {
1277
+ return null;
1278
+ }
1279
+ },
1280
+ subscribe(cb) {
1281
+ const notify = () => {
1282
+ try {
1283
+ cb();
1284
+ } catch (_unused3) {}
1285
+ };
1286
+ const offReady = listenToWindowEvents(STATE_READY_EVENTS, () => {
1287
+ stateLoading = false;
1288
+ unavailable = false;
1289
+ notify();
1290
+ });
1291
+ const offLoading = listenToWindowEvents(["concord-consent-state-loading"], () => {
1292
+ stateLoading = true;
1293
+ });
1294
+ const offUnavailable = listenToWindowEvents(["concord-unavailable"], () => {
1295
+ unavailable = true;
1296
+ });
1297
+ return () => {
1298
+ offReady();
1299
+ offLoading();
1300
+ offUnavailable();
1301
+ };
1302
+ }
1303
+ };
1304
+ };
1305
+ //#endregion
1306
+ //#region src/consent-sources/gcm-reader.ts
1307
+ /**
1308
+ * Google Consent Mode v2 READER.
1309
+ *
1310
+ * Strictly read-only: this source reads the current Google Consent Mode state
1311
+ * that some *other* tool on the page (a CMP, gtag bootstrap, GTM) has set, and
1312
+ * maps it onto Leapio's categories. It NEVER calls `gtag('consent', ...)` or
1313
+ * mutates Google state in any way — Leapio is a consumer of consent, never a
1314
+ * writer into Google.
1315
+ *
1316
+ * ## Where the state is read from
1317
+ *
1318
+ * gtag's bootstrap is `function gtag(){dataLayer.push(arguments)}`, so every
1319
+ * `gtag('consent','default'|'update', {...})` pushes the function's *arguments*
1320
+ * object (array-like: `entry[0]='consent'`, `entry[1]='default'|'update'`,
1321
+ * `entry[2]={signal:'granted'|'denied', ...}`) onto `window.dataLayer`. We scan
1322
+ * `dataLayer` in order and fold every consent entry into a per-signal map; later
1323
+ * entries win, so an `update` issued after the user chooses supersedes the
1324
+ * initial `default` (Google docs: "effective state = update when present, else
1325
+ * default").
1326
+ * Source: https://developers.google.com/tag-platform/security/guides/consent
1327
+ *
1328
+ * As a best-effort *fallback* (when no scannable dataLayer entries exist, e.g. a
1329
+ * CMP registered the default internally), we read the internal consent state at
1330
+ * `window.google_tag_data.ics`. That object is INTERNAL/UNDOCUMENTED (it does
1331
+ * not appear in any developers.google.com page) and may break across gtag.js
1332
+ * releases, so it is only consulted when the documented dataLayer scan yields
1333
+ * nothing.
1334
+ * Source (non-authoritative, flagged): https://gunnargriese.com/posts/consent-mode-v2/
1335
+ *
1336
+ * ## Mapping
1337
+ *
1338
+ * analytics_storage -> analytics
1339
+ * functionality_storage + personalization_storage -> functional (AND)
1340
+ * ad_storage + ad_user_data + ad_personalization -> marketing (AND)
1341
+ *
1342
+ * ### Why AND across the functional/marketing groups
1343
+ *
1344
+ * Multiple GCM signals map onto a single Leapio category. GCM signals
1345
+ * are independently grantable, so a group can be split (e.g. `ad_storage`
1346
+ * granted but `ad_personalization` denied). Leapio categories gate downstream
1347
+ * *use* — granting `marketing` authorizes advertising use of the data. We choose
1348
+ * the CONSERVATIVE / least-privilege reading: a Leapio category is granted only
1349
+ * when EVERY GCM signal that maps to it is `granted`. If any contributing signal
1350
+ * is denied, the category is denied. This guarantees we never over-permit
1351
+ * marketing/functional use relative to what the user told Google. Verified
1352
+ * against Google's GCM v2 semantics (each signal carries an independent
1353
+ * granted/denied value; there is no documented roll-up), so the AND choice is a
1354
+ * deliberate policy decision, not an inference about Google's own roll-up.
1355
+ *
1356
+ * A signal that is *absent entirely* (never set) is treated as "unknown" and
1357
+ * does not by itself deny the category — only an explicit `denied` denies. A
1358
+ * category is reported only when at least one of its contributing signals has an
1359
+ * explicit value; categories with no information stay absent from the snapshot
1360
+ * (so they fall through to the org's `consentBehavior` server-side, exactly like
1361
+ * any other source that "never spoke" about a category).
1362
+ */
1363
+ const SOURCE_NAME$3 = "gcm";
1364
+ const PRIORITY$3 = 40;
1365
+ /** The GCM signals each Leapio category is composed from (conservative AND). */
1366
+ const CATEGORY_SIGNALS = {
1367
+ analytics: ["analytics_storage"],
1368
+ functional: ["functionality_storage", "personalization_storage"],
1369
+ marketing: [
1370
+ "ad_storage",
1371
+ "ad_user_data",
1372
+ "ad_personalization"
1373
+ ]
1374
+ };
1375
+ const ALL_SIGNALS = [
1376
+ "analytics_storage",
1377
+ "functionality_storage",
1378
+ "personalization_storage",
1379
+ "ad_storage",
1380
+ "ad_user_data",
1381
+ "ad_personalization"
1382
+ ];
1383
+ const isGcmValue = (value) => value === "granted" || value === "denied";
1384
+ /**
1385
+ * Fold the `window.dataLayer` consent entries into a per-signal value map.
1386
+ * Scans in push order so later (`update`) entries override earlier (`default`)
1387
+ * ones on a per-signal basis. Region scoping is ignored in V1: every matching
1388
+ * `default`/`update` is applied in order, so the last writer for each signal
1389
+ * wins (a documented simplification — region-specific defaults exist but are not
1390
+ * resolved here). Returns `null` when no consent entries are present.
1391
+ */
1392
+ const readFromDataLayer = () => {
1393
+ let dataLayer;
1394
+ try {
1395
+ dataLayer = window.dataLayer;
1396
+ } catch (_unused) {
1397
+ return null;
1398
+ }
1399
+ if (!Array.isArray(dataLayer)) return null;
1400
+ const signals = {};
1401
+ let sawConsentEntry = false;
1402
+ for (const entry of dataLayer) {
1403
+ if (!entry || typeof entry !== "object") continue;
1404
+ const arrayLike = entry;
1405
+ if (typeof arrayLike.length !== "number" || arrayLike.length < 3) continue;
1406
+ if (arrayLike[0] !== "consent") continue;
1407
+ const mode = arrayLike[1];
1408
+ if (mode !== "default" && mode !== "update") continue;
1409
+ const params = arrayLike[2];
1410
+ if (!params || typeof params !== "object") continue;
1411
+ sawConsentEntry = true;
1412
+ for (const signal of ALL_SIGNALS) {
1413
+ const value = params[signal];
1414
+ if (isGcmValue(value)) signals[signal] = value;
1415
+ }
1416
+ }
1417
+ return sawConsentEntry ? signals : null;
1418
+ };
1419
+ /**
1420
+ * Best-effort fallback: read the internal consent state at
1421
+ * `window.google_tag_data.ics`. INTERNAL/UNDOCUMENTED — only consulted when the
1422
+ * documented dataLayer scan yields nothing. The `ics.entries` map is keyed by
1423
+ * signal; each entry exposes `update`/`default` (update supersedes) and/or a
1424
+ * `getConsentState(signal)` returning `1` (granted) / `0` (denied). We read
1425
+ * conservatively and tolerate any shape mismatch.
1426
+ */
1427
+ const readFromInternalState = () => {
1428
+ let ics;
1429
+ try {
1430
+ var _google_tag_data;
1431
+ ics = (_google_tag_data = window.google_tag_data) === null || _google_tag_data === void 0 ? void 0 : _google_tag_data.ics;
1432
+ } catch (_unused2) {
1433
+ return null;
1434
+ }
1435
+ if (!ics || typeof ics !== "object") return null;
1436
+ const signals = {};
1437
+ let sawAny = false;
1438
+ const entries = ics.entries;
1439
+ if (entries && typeof entries === "object") for (const signal of ALL_SIGNALS) {
1440
+ const entry = entries[signal];
1441
+ if (!entry || typeof entry !== "object") continue;
1442
+ const record = entry;
1443
+ const raw = record.update !== void 0 ? record.update : record.default;
1444
+ if (raw === true) {
1445
+ signals[signal] = "granted";
1446
+ sawAny = true;
1447
+ } else if (raw === false) {
1448
+ signals[signal] = "denied";
1449
+ sawAny = true;
1450
+ } else if (isGcmValue(raw)) {
1451
+ signals[signal] = raw;
1452
+ sawAny = true;
1453
+ }
1454
+ }
1455
+ return sawAny ? signals : null;
1456
+ };
1457
+ /**
1458
+ * Compute one Leapio category from its GCM signals (conservative AND):
1459
+ * - `false` if ANY contributing signal is explicitly `denied`,
1460
+ * - `true` if ALL contributing signals are explicitly `granted`,
1461
+ * - `undefined` (category omitted) if no contributing signal has a value.
1462
+ */
1463
+ const resolveCategory = (category, signals) => {
1464
+ const contributing = CATEGORY_SIGNALS[category];
1465
+ let sawAny = false;
1466
+ let allGranted = true;
1467
+ for (const signal of contributing) {
1468
+ const value = signals[signal];
1469
+ if (value === void 0) {
1470
+ allGranted = false;
1471
+ continue;
1472
+ }
1473
+ sawAny = true;
1474
+ if (value === "denied") return false;
1475
+ }
1476
+ if (!sawAny || !allGranted) return void 0;
1477
+ return true;
1478
+ };
1479
+ const buildSnapshot$2 = (signals) => {
1480
+ const categories = {};
1481
+ let sawAny = false;
1482
+ Object.keys(CATEGORY_SIGNALS).forEach((category) => {
1483
+ const resolved = resolveCategory(category, signals);
1484
+ if (resolved !== void 0) {
1485
+ categories[category] = resolved;
1486
+ sawAny = true;
1487
+ }
1488
+ });
1489
+ if (!sawAny) return null;
1490
+ return {
1491
+ categories,
1492
+ signals: {},
1493
+ source: SOURCE_NAME$3,
1494
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1495
+ sourceMetadata: { gcm: signals }
1496
+ };
1497
+ };
1498
+ /** Read the current GCM state: documented dataLayer scan, then internal fallback. */
1499
+ const readGcmState = () => {
1500
+ var _readFromDataLayer;
1501
+ return (_readFromDataLayer = readFromDataLayer()) !== null && _readFromDataLayer !== void 0 ? _readFromDataLayer : readFromInternalState();
1502
+ };
1503
+ /**
1504
+ * Construct the Google Consent Mode reader source. Always returns a source; its
1505
+ * `read()` yields `null` until consent entries are observable (so the registry
1506
+ * keeps it registered and re-reads on subscribe callbacks safely even when GCM
1507
+ * is not yet present).
1508
+ */
1509
+ const createGcmReaderSource = () => {
1510
+ return {
1511
+ name: SOURCE_NAME$3,
1512
+ priority: PRIORITY$3,
1513
+ read() {
1514
+ try {
1515
+ const signals = readGcmState();
1516
+ if (!signals) return null;
1517
+ return buildSnapshot$2(signals);
1518
+ } catch (_unused3) {
1519
+ return null;
1520
+ }
1521
+ },
1522
+ subscribe(cb) {
1523
+ let dataLayer;
1524
+ try {
1525
+ const win = window;
1526
+ if (!Array.isArray(win.dataLayer)) {
1527
+ var _win$dataLayer;
1528
+ win.dataLayer = (_win$dataLayer = win.dataLayer) !== null && _win$dataLayer !== void 0 ? _win$dataLayer : [];
1529
+ }
1530
+ dataLayer = win.dataLayer;
1531
+ } catch (_unused4) {
1532
+ return () => {};
1533
+ }
1534
+ if (!Array.isArray(dataLayer)) return () => {};
1535
+ const arr = dataLayer;
1536
+ const originalPush = arr.push;
1537
+ const wrapped = (...items) => {
1538
+ const result = originalPush.apply(arr, items);
1539
+ for (const item of items) {
1540
+ const arrayLike = item;
1541
+ if (arrayLike && typeof arrayLike === "object" && arrayLike[0] === "consent") {
1542
+ try {
1543
+ cb();
1544
+ } catch (_unused5) {}
1545
+ break;
1546
+ }
1547
+ }
1548
+ return result;
1549
+ };
1550
+ try {
1551
+ arr.push = wrapped;
1552
+ } catch (_unused6) {
1553
+ return () => {};
1554
+ }
1555
+ return () => {
1556
+ try {
1557
+ if (arr.push === wrapped) arr.push = originalPush;
1558
+ } catch (_unused7) {}
1559
+ };
1560
+ }
1561
+ };
1562
+ };
1563
+ //#endregion
1564
+ //#region src/consent-sources/onetrust.ts
1565
+ /**
1566
+ * OneTrust Cookie Consent adapter. Read-only.
1567
+ *
1568
+ * ## API surface (verified against OneTrust developer docs)
1569
+ *
1570
+ * - `window.OnetrustActiveGroups` — a comma-DELIMITED string of the currently
1571
+ * active category group IDs, with LEADING and TRAILING commas, e.g.
1572
+ * `,C0001,C0002,C0004,`. The bounding commas exist precisely so callers can
1573
+ * test membership with `.indexOf(',C0002,')` without `C0002` false-matching
1574
+ * `C0012`.
1575
+ * Source: https://developer.onetrust.com/onetrust/docs/google-tag-manager
1576
+ * Source: https://my.onetrust.com/s/article/UUID-66bcaaf1-c7ca-5f32-6760-c75a1337c226
1577
+ *
1578
+ * - `OneTrustGroupsUpdated` window event — dispatched both on initial script
1579
+ * load AND whenever the user updates consent; `event.detail` is an array of
1580
+ * active group IDs (e.g. `["C0001","C0002"]`). This is the cleanest change
1581
+ * hook (no string parsing, no load-order dependency), so we subscribe to it.
1582
+ * Source: https://developer.onetrust.com/onetrust/docs/javascript-events-guide
1583
+ *
1584
+ * - `window.OneTrust.OnConsentChanged(cb)` — a zero-argument change callback;
1585
+ * used as a fallback subscription when the window event is unavailable. Inside
1586
+ * it we re-read `OnetrustActiveGroups`.
1587
+ * Source: https://developer.onetrust.com/onetrust/docs/javascript-api
1588
+ *
1589
+ * - `OneTrust.IsAlertBoxClosed()`: true when the OptanonAlertBoxClosed cookie is set and consent is valid; that cookie is written on Accept All, Reject All, Close (banner), and Save Settings. Sources: https://developer.onetrust.com/onetrust/docs/javascript-api and https://my.onetrust.com/s/article/UUID-2dc719a8-4be5-8d16-1dc8-c7b4147b88e0
1590
+ *
1591
+ * ## Category mapping (OneTrust default group IDs -> Leapio categories)
1592
+ *
1593
+ * C0001 Strictly Necessary -> (no Leapio category; always-on, not mapped)
1594
+ * C0002 Performance -> analytics // OneTrust's own GCM example maps C0002 -> analytics_storage
1595
+ * C0003 Functional -> functional
1596
+ * C0004 Targeting -> marketing // OneTrust maps C0004 -> ad_storage/ad_* in its GCM example
1597
+ * C0005 Social Media -> marketing // social media = cross-site advertising/profiling
1598
+ * Source (defaults + GCM mapping precedent): https://developer.onetrust.com/onetrust/docs/javascript-api
1599
+ *
1600
+ * CAVEAT: C0001–C0005 are OneTrust's OUT-OF-THE-BOX defaults and are
1601
+ * tenant-CUSTOMIZABLE (group IDs and even category labels can be changed per
1602
+ * tenant). We map the standard defaults; tenants using custom group IDs would
1603
+ * need a configurable mapping (a documented V1 limitation). Unknown/custom group
1604
+ * IDs are simply ignored (they grant no Leapio category).
1605
+ *
1606
+ * We report a category as `true` when its mapped group is active. We do NOT emit
1607
+ * `false` for inactive groups: OneTrust's active-groups string lists only what
1608
+ * is *granted*, and after a consent decision a group's absence means denied —
1609
+ * but on first load (opt-in model) only C0001 is present and the rest are
1610
+ * "pending". To stay conservative and avoid asserting a denial the user has not
1611
+ * made, an absent mapped group leaves that category UNSET (falls through to the
1612
+ * org's consentBehavior server-side). Once at least one non-necessary group is
1613
+ * active we treat absent mapped groups as explicit denials, since that indicates
1614
+ * the banner has been resolved. When only necessary groups are active, `IsAlertBoxClosed()` (with the OptanonAlertBoxClosed cookie as fallback when the API object is absent) disambiguates:
1615
+ * true means the banner was resolved (Reject All / dismissed), so we emit an all-false denial snapshot; false or absent keeps the null pre-decision fallthrough.
1616
+ */
1617
+ const SOURCE_NAME$2 = "onetrust";
1618
+ const PRIORITY$2 = 60;
1619
+ /** Default OneTrust group ID -> Leapio category. C0001 (necessary) is unmapped. */
1620
+ const GROUP_TO_CATEGORY = {
1621
+ C0002: "analytics",
1622
+ C0003: "functional",
1623
+ C0004: "marketing",
1624
+ C0005: "marketing"
1625
+ };
1626
+ const getActiveGroupsString = () => {
1627
+ try {
1628
+ const value = window.OnetrustActiveGroups;
1629
+ return typeof value === "string" ? value : null;
1630
+ } catch (_unused) {
1631
+ return null;
1632
+ }
1633
+ };
1634
+ const getOneTrust = () => {
1635
+ try {
1636
+ const ot = window.OneTrust;
1637
+ return ot && typeof ot === "object" ? ot : null;
1638
+ } catch (_unused2) {
1639
+ return null;
1640
+ }
1641
+ };
1642
+ const isAlertBoxClosed = () => {
1643
+ try {
1644
+ const ot = getOneTrust();
1645
+ if (ot && typeof ot.IsAlertBoxClosed === "function") return ot.IsAlertBoxClosed() === true;
1646
+ } catch (_unused3) {
1647
+ return false;
1648
+ }
1649
+ try {
1650
+ return document.cookie.indexOf("OptanonAlertBoxClosed=") !== -1;
1651
+ } catch (_unused4) {
1652
+ return false;
1653
+ }
1654
+ };
1655
+ /** Parse the comma-bounded active-groups string into a Set of group IDs. */
1656
+ const parseActiveGroups = (raw) => {
1657
+ return new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0));
1658
+ };
1659
+ const buildSnapshot$1 = (raw) => {
1660
+ const active = parseActiveGroups(raw);
1661
+ let anyNonNecessaryActive = false;
1662
+ let anyNonNecessaryActiveRaw = false;
1663
+ active.forEach((id) => {
1664
+ if (id !== "C0001") {
1665
+ anyNonNecessaryActiveRaw = true;
1666
+ if (GROUP_TO_CATEGORY[id]) anyNonNecessaryActive = true;
1667
+ }
1668
+ });
1669
+ const categories = {};
1670
+ for (const [groupId, category] of Object.entries(GROUP_TO_CATEGORY)) if (active.has(groupId)) categories[category] = true;
1671
+ else if (anyNonNecessaryActive && categories[category] !== true) categories[category] = false;
1672
+ if (Object.keys(categories).length === 0) {
1673
+ if (!isAlertBoxClosed()) return null;
1674
+ if (anyNonNecessaryActiveRaw) return null;
1675
+ return {
1676
+ categories: {
1677
+ analytics: false,
1678
+ functional: false,
1679
+ marketing: false
1680
+ },
1681
+ signals: {},
1682
+ source: SOURCE_NAME$2,
1683
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1684
+ sourceMetadata: {
1685
+ activeGroups: Array.from(active),
1686
+ alertBoxClosed: true
1687
+ }
1688
+ };
1689
+ }
1690
+ return {
1691
+ categories,
1692
+ signals: {},
1693
+ source: SOURCE_NAME$2,
1694
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1695
+ sourceMetadata: { activeGroups: Array.from(active) }
1696
+ };
1697
+ };
1698
+ /**
1699
+ * Construct the OneTrust source. Always returns a source; `read()` yields `null`
1700
+ * until `OnetrustActiveGroups` is present, so the registry registers it safely
1701
+ * even before the OneTrust script loads.
1702
+ */
1703
+ const createOneTrustSource = () => {
1704
+ return {
1705
+ name: SOURCE_NAME$2,
1706
+ priority: PRIORITY$2,
1707
+ read() {
1708
+ try {
1709
+ const raw = getActiveGroupsString();
1710
+ if (raw === null) return null;
1711
+ return buildSnapshot$1(raw);
1712
+ } catch (_unused5) {
1713
+ return null;
1714
+ }
1715
+ },
1716
+ subscribe(cb) {
1717
+ const notify = () => {
1718
+ try {
1719
+ cb();
1720
+ } catch (_unused6) {}
1721
+ };
1722
+ const off = listenToWindowEvents(["OneTrustGroupsUpdated"], notify);
1723
+ try {
1724
+ const ot = getOneTrust();
1725
+ if (ot && typeof ot.OnConsentChanged === "function") ot.OnConsentChanged(notify);
1726
+ } catch (_unused7) {}
1727
+ return off;
1728
+ }
1729
+ };
1730
+ };
1731
+ //#endregion
1732
+ //#region src/consent-sources/usercentrics.ts
1733
+ /**
1734
+ * Usercentrics adapter. Read-only.
1735
+ *
1736
+ * ## API surface (verified against Usercentrics docs)
1737
+ *
1738
+ * Usercentrics has TWO browser API generations and we support both:
1739
+ *
1740
+ * - V2 (hosted "UC_UI" CMP): dispatches the `ucEvent` window event. Its
1741
+ * `e.detail.event === 'consent_status'` payload carries a per-category rollup
1742
+ * object `e.detail.ucCategory`, where each category slug maps to
1743
+ * `true` (all services consented) / `false` (all denied) / `null` (mixed).
1744
+ * Init is signalled by `UC_UI_INITIALIZED`.
1745
+ * Source: https://usercentrics.com/docs/web/features/v2/events_v2/events_v2/
1746
+ * Source: https://usercentrics.com/docs/web/features/events/consent-events/
1747
+ *
1748
+ * - V3 (latest "Web CMP", `window.__ucCmp`): dispatches the `UC_CONSENT` window
1749
+ * event whose `e.detail` is a `ConsentDetails` object. `detail.categories` is a
1750
+ * record keyed by category id; each value has a `state` of
1751
+ * `'ALL_DENIED' | 'SOME_ACCEPTED' | 'ALL_ACCEPTED'`. `window.__ucCmp` also
1752
+ * exposes `getConsentDetails(): Promise<ConsentDetails>` (async — not used in
1753
+ * the synchronous `read()`; we rely on the cached event detail instead).
1754
+ * Source: https://usercentrics.com/docs/web/features/api/control-functionality/
1755
+ * Source: https://usercentrics.com/docs/web/features/api/interfaces/
1756
+ *
1757
+ * Because `read()` must be synchronous and the hosted CMP has no synchronous
1758
+ * category getter, this adapter CACHES the most recent consent state seen on the
1759
+ * `ucEvent` / `UC_CONSENT` events (and on any pre-existing `window.__ucCmp`
1760
+ * detail captured at construction) and serves `read()` from that cache.
1761
+ *
1762
+ * ## Category mapping (Usercentrics slugs -> Leapio)
1763
+ *
1764
+ * Usercentrics categories are CONFIGURABLE per account, so there is no fixed
1765
+ * enum. The slugs guaranteed by Usercentrics' own examples are `essential`,
1766
+ * `functional`, `marketing` (+ `payment`, essential). We map by slug, matching
1767
+ * common conventions case-insensitively:
1768
+ *
1769
+ * essential / necessary -> (unmapped; always-on)
1770
+ * functional / preferences -> functional
1771
+ * marketing / advertising -> marketing
1772
+ * analytics / statistics / performance -> analytics
1773
+ *
1774
+ * CAVEAT: because categories are account-configurable, a tenant may use a slug
1775
+ * we don't recognize (it is then ignored — granted nothing). This is a
1776
+ * documented V1 limitation; a configurable mapping would be a follow-up.
1777
+ *
1778
+ * A category rollup of `null` (V2 "mixed") or `'SOME_ACCEPTED'` (V3) is treated
1779
+ * conservatively as NOT granted (we only grant on a definitive all-accepted).
1780
+ */
1781
+ const SOURCE_NAME$1 = "usercentrics";
1782
+ const PRIORITY$1 = 60;
1783
+ /** Normalized slug -> Leapio category. Keys are compared lower-cased. */
1784
+ const SLUG_TO_CATEGORY = {
1785
+ functional: "functional",
1786
+ preferences: "functional",
1787
+ marketing: "marketing",
1788
+ advertising: "marketing",
1789
+ analytics: "analytics",
1790
+ statistics: "analytics",
1791
+ performance: "analytics"
1792
+ };
1793
+ const mapSlug = (slug) => {
1794
+ return SLUG_TO_CATEGORY[slug.trim().toLowerCase()];
1795
+ };
1796
+ /**
1797
+ * Fold a per-slug grant map into Leapio categories. When multiple slugs map to
1798
+ * the same Leapio category (e.g. analytics + statistics), a grant wins over a
1799
+ * denial (most-permissive within a single mapped category), but an explicit
1800
+ * denial still records `false` if no mapped slug granted it.
1801
+ */
1802
+ const foldSlugGrants = (slugGrants) => {
1803
+ const categories = {};
1804
+ for (const { slug, granted } of slugGrants) {
1805
+ const category = mapSlug(slug);
1806
+ if (!category) continue;
1807
+ if (granted === true) categories[category] = true;
1808
+ else if (categories[category] !== true) categories[category] = false;
1809
+ }
1810
+ return categories;
1811
+ };
1812
+ /** Parse a V2 `ucEvent` detail (`ucCategory` rollup object). */
1813
+ const parseV2Detail = (detail) => {
1814
+ if (!detail || typeof detail !== "object") return null;
1815
+ const d = detail;
1816
+ if (d.event !== "consent_status") return null;
1817
+ const ucCategory = d.ucCategory;
1818
+ if (!ucCategory || typeof ucCategory !== "object") return null;
1819
+ const slugGrants = Object.entries(ucCategory).map(([slug, value]) => ({
1820
+ slug,
1821
+ granted: value === true ? true : value === false ? false : null
1822
+ }));
1823
+ const categories = foldSlugGrants(slugGrants);
1824
+ return Object.keys(categories).length > 0 ? categories : null;
1825
+ };
1826
+ /** Parse a V3 `UC_CONSENT` detail (`ConsentDetails.categories`). */
1827
+ const parseV3Detail = (detail) => {
1828
+ if (!detail || typeof detail !== "object") return null;
1829
+ const categoriesRecord = detail.categories;
1830
+ if (!categoriesRecord || typeof categoriesRecord !== "object") return null;
1831
+ const slugGrants = Object.entries(categoriesRecord).map(([slug, value]) => {
1832
+ const state = value && typeof value === "object" ? value.state : void 0;
1833
+ return {
1834
+ slug,
1835
+ granted: state === "ALL_ACCEPTED" ? true : state === "ALL_DENIED" ? false : null
1836
+ };
1837
+ });
1838
+ const categories = foldSlugGrants(slugGrants);
1839
+ return Object.keys(categories).length > 0 ? categories : null;
1840
+ };
1841
+ const parseAnyDetail = (detail) => {
1842
+ var _parseV3Detail;
1843
+ return (_parseV3Detail = parseV3Detail(detail)) !== null && _parseV3Detail !== void 0 ? _parseV3Detail : parseV2Detail(detail);
1844
+ };
1845
+ /**
1846
+ * Construct the Usercentrics source. Caches the latest consent state from the
1847
+ * vendor's events; `read()` returns `null` until a consent event has been seen.
1848
+ */
1849
+ const createUsercentricsSource = () => {
1850
+ let cached = null;
1851
+ return {
1852
+ name: SOURCE_NAME$1,
1853
+ priority: PRIORITY$1,
1854
+ read() {
1855
+ try {
1856
+ if (!cached || Object.keys(cached).length === 0) return null;
1857
+ return {
1858
+ categories: _objectSpread2({}, cached),
1859
+ signals: {},
1860
+ source: SOURCE_NAME$1,
1861
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1862
+ };
1863
+ } catch (_unused) {
1864
+ return null;
1865
+ }
1866
+ },
1867
+ subscribe(cb) {
1868
+ const onDetail = (detail) => {
1869
+ const parsed = parseAnyDetail(detail);
1870
+ if (parsed) {
1871
+ cached = parsed;
1872
+ try {
1873
+ cb();
1874
+ } catch (_unused2) {}
1875
+ }
1876
+ };
1877
+ return listenToWindowEvents(["ucEvent", "UC_CONSENT"], (event) => {
1878
+ onDetail(event.detail);
1879
+ });
1880
+ }
1881
+ };
1882
+ };
1883
+ //#endregion
1884
+ //#region src/consent-sources/cookiebot.ts
1885
+ /**
1886
+ * Cookiebot adapter. Read-only.
1887
+ *
1888
+ * ## API surface (verified against the Cookiebot developer reference)
1889
+ * Source: https://www.cookiebot.com/en/developer/
1890
+ *
1891
+ * - `window.Cookiebot.consent` — read-only booleans for Cookiebot's four fixed
1892
+ * categories: `necessary`, `preferences`, `statistics`, `marketing`. These are
1893
+ * a fixed enum (unlike Usercentrics' configurable categories).
1894
+ * - `window.Cookiebot.hasResponse` — `true` once the user has responded (accept
1895
+ * or decline). Used to distinguish "no decision yet" (all booleans default
1896
+ * false / necessary true) from "explicitly all-denied".
1897
+ * - `window.Cookiebot.consent.method` — `'implied' | 'explicit'` (or null).
1898
+ * - Events (all dispatched on `window`):
1899
+ * `CookiebotOnConsentReady` — fires when consent is ready, whether freshly
1900
+ * submitted OR loaded from an existing cookie (read-on-load + every
1901
+ * change). This is the primary subscription.
1902
+ * `CookiebotOnAccept` / `CookiebotOnDecline` — explicit user actions (also
1903
+ * re-fire for returning consented users); subscribed as a backstop.
1904
+ *
1905
+ * ## Category mapping (Cookiebot -> Leapio)
1906
+ *
1907
+ * necessary -> (always-on; not mapped to a Leapio category)
1908
+ * preferences -> functional // "remember user choices (language, region...)"
1909
+ * statistics -> analytics // "anonymous analytics / how visitors use the site"
1910
+ * marketing -> marketing // "tracking for ads / cross-site profiling"
1911
+ *
1912
+ * We map each Cookiebot boolean directly. Because Cookiebot exposes explicit
1913
+ * booleans (not just a granted-list), we emit `true`/`false` faithfully — but
1914
+ * only once `hasResponse` is true. Before a response, `read()` returns `null`
1915
+ * so the snapshot falls through to the org's consentBehavior server-side rather
1916
+ * than asserting denials the user has not made.
1917
+ */
1918
+ const SOURCE_NAME = "cookiebot";
1919
+ const PRIORITY = 60;
1920
+ /** Cookiebot category -> Leapio category (necessary is intentionally unmapped). */
1921
+ const CONSENT_FIELD_TO_CATEGORY = [
1922
+ {
1923
+ field: "preferences",
1924
+ category: "functional"
1925
+ },
1926
+ {
1927
+ field: "statistics",
1928
+ category: "analytics"
1929
+ },
1930
+ {
1931
+ field: "marketing",
1932
+ category: "marketing"
1933
+ }
1934
+ ];
1935
+ const getCookiebot = () => {
1936
+ try {
1937
+ const cb = window.Cookiebot;
1938
+ return cb && typeof cb === "object" ? cb : null;
1939
+ } catch (_unused) {
1940
+ return null;
1941
+ }
1942
+ };
1943
+ const buildSnapshot = (cb) => {
1944
+ const consent = cb.consent;
1945
+ if (!consent || typeof consent !== "object") return null;
1946
+ if (cb.hasResponse !== true) return null;
1947
+ const categories = {};
1948
+ for (const { field, category } of CONSENT_FIELD_TO_CATEGORY) {
1949
+ const value = consent[field];
1950
+ if (typeof value === "boolean") categories[category] = value;
1951
+ }
1952
+ if (Object.keys(categories).length === 0) return null;
1953
+ return {
1954
+ categories,
1955
+ signals: {},
1956
+ source: SOURCE_NAME,
1957
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1958
+ sourceMetadata: { method: typeof consent.method === "string" ? consent.method : void 0 }
1959
+ };
1960
+ };
1961
+ /**
1962
+ * Construct the Cookiebot source. Always returns a source; `read()` yields
1963
+ * `null` until `window.Cookiebot` exists and the user has responded.
1964
+ */
1965
+ const createCookiebotSource = () => {
1966
+ return {
1967
+ name: SOURCE_NAME,
1968
+ priority: PRIORITY,
1969
+ read() {
1970
+ try {
1971
+ const cb = getCookiebot();
1972
+ if (!cb) return null;
1973
+ return buildSnapshot(cb);
1974
+ } catch (_unused2) {
1975
+ return null;
1976
+ }
1977
+ },
1978
+ subscribe(cb) {
1979
+ const notify = () => {
1980
+ try {
1981
+ cb();
1982
+ } catch (_unused3) {}
1983
+ };
1984
+ return listenToWindowEvents([
1985
+ "CookiebotOnConsentReady",
1986
+ "CookiebotOnAccept",
1987
+ "CookiebotOnDecline"
1988
+ ], notify);
1989
+ }
1990
+ };
1991
+ };
1992
+ //#endregion
1993
+ //#region src/consent-sources/index.ts
1994
+ /**
1995
+ * Consent-source registry.
1996
+ *
1997
+ * Maps the source names a customer puts in `config.consentSources` (plumbed from
1998
+ * the CDN wrapper's `data-consent-source` attribute) to their factory. Accepts
1999
+ * both the canonical source ids and a couple of friendly aliases.
2000
+ */
2001
+ const SOURCE_FACTORIES = {
2002
+ concord: createConcordSource,
2003
+ onetrust: createOneTrustSource,
2004
+ usercentrics: createUsercentricsSource,
2005
+ cookiebot: createCookiebotSource,
2006
+ gcm: createGcmReaderSource,
2007
+ google_consent_mode: createGcmReaderSource
2008
+ };
2009
+ const normalizeName = (name) => name.trim().toLowerCase();
2010
+ /**
2011
+ * Instantiate the consent sources named in `names`. Unknown names are skipped
2012
+ * (logged once); a factory returning `null` is skipped silently. Duplicate names
2013
+ * collapse to a single instance. Never throws.
2014
+ */
2015
+ const instantiateConsentSources = (names) => {
2016
+ const seen = /* @__PURE__ */ new Set();
2017
+ const sources = [];
2018
+ for (const rawName of names) {
2019
+ const key = normalizeName(rawName);
2020
+ if (!key || seen.has(key)) continue;
2021
+ seen.add(key);
2022
+ const factory = SOURCE_FACTORIES[key];
2023
+ if (!factory) {
2024
+ try {
2025
+ console.warn(`Leapio - unknown consent source "${rawName}" ignored`);
2026
+ } catch (_unused) {}
2027
+ continue;
2028
+ }
2029
+ try {
2030
+ const source = factory();
2031
+ if (source) sources.push(source);
2032
+ } catch (_unused2) {}
2033
+ }
2034
+ return sources;
2035
+ };
2036
+ /**
2037
+ * Owns the lifecycle of the registered consent sources. On `start()` it reads
2038
+ * each source's current snapshot, applies it through `applySnapshot` (the store
2039
+ * enforces priority, so order does not matter), and subscribes to each source's
2040
+ * change events — re-reading and re-applying on every callback. `stop()` tears
2041
+ * down every subscription (used by `reset()`).
2042
+ *
2043
+ * `applySnapshot` is the SDK seam (`leapio.applyConsentSnapshot`): it returns a
2044
+ * promise resolving to whether the snapshot became authoritative. The registry
2045
+ * fires it and does not block source setup on the resulting `/consent` POST.
2046
+ */
2047
+ var ConsentSourceRegistry = class {
2048
+ constructor(sources, applySnapshot) {
2049
+ this.applySnapshot = applySnapshot;
2050
+ this.unsubscribers = [];
2051
+ this.started = false;
2052
+ this.sources = sources;
2053
+ }
2054
+ /** Number of registered sources (0 means the registry is a no-op). */
2055
+ get size() {
2056
+ return this.sources.length;
2057
+ }
2058
+ /**
2059
+ * Read each source's current state and subscribe for changes. Safe to call
2060
+ * when no vendor global is present yet: `read()` returns `null` (nothing
2061
+ * applied) and the subscriptions stay live so a later vendor load is picked
2062
+ * up. Idempotent — a second call is a no-op.
2063
+ */
2064
+ start() {
2065
+ if (this.started) return;
2066
+ this.started = true;
2067
+ for (const source of this.sources) {
2068
+ try {
2069
+ const unsubscribe = source.subscribe(() => {
2070
+ this.applyFrom(source);
2071
+ });
2072
+ if (typeof unsubscribe === "function") this.unsubscribers.push(unsubscribe);
2073
+ } catch (_unused3) {}
2074
+ this.applyFrom(source);
2075
+ }
2076
+ }
2077
+ /** Read one source and push its snapshot through the priority-aware store. */
2078
+ applyFrom(source) {
2079
+ let snapshot = null;
2080
+ try {
2081
+ snapshot = source.read();
2082
+ } catch (_unused4) {
2083
+ snapshot = null;
2084
+ }
2085
+ if (!snapshot) return;
2086
+ this.applySnapshot(snapshot).catch(() => {});
2087
+ }
2088
+ /** Tear down every subscription. Called by `reset()`. Idempotent. */
2089
+ stop() {
2090
+ while (this.unsubscribers.length > 0) {
2091
+ const off = this.unsubscribers.pop();
2092
+ try {
2093
+ off === null || off === void 0 || off();
2094
+ } catch (_unused5) {}
2095
+ }
2096
+ this.started = false;
2097
+ }
2098
+ };
2099
+ //#endregion
2100
+ //#region src/autocapture.ts
2101
+ /** Attribute used to fire a declarative event on click. */
2102
+ const DECLARATIVE_EVENT_ATTR = "data-leapio-event";
2103
+ const LEGACY_DECLARATIVE_EVENT_ATTR = "data-lgspt-event";
2104
+ const LEGACY_DECLARATIVE_PROP_PREFIX = "data-lgspt-prop-";
2105
+ const LEGACY_SENSITIVE_ATTR = "data-lgspt-sensitive";
2106
+ /** Prefix for declarative metadata props, e.g. `data-leapio-prop-plan="pro"`. */
2107
+ const DECLARATIVE_PROP_PREFIX = "data-leapio-prop-";
2108
+ const SENSITIVE_SELECTOR = `[data-leapio-sensitive],[${LEGACY_SENSITIVE_ATTR}]`;
2109
+ /**
2110
+ * File extensions treated as downloads. A click on an `a[href]` whose path ends
2111
+ * in one of these (case-insensitive) emits `File Downloaded`. Pure metadata —
2112
+ * no PII. (An explicit `download` attribute also qualifies, regardless of
2113
+ * extension.) This is the SDK's built-in default; the server config can replace
2114
+ * it per project.
2115
+ */
2116
+ const DOWNLOAD_EXTENSIONS = [
2117
+ "pdf",
2118
+ "zip",
2119
+ "csv",
2120
+ "xlsx",
2121
+ "doc",
2122
+ "docx",
2123
+ "ppt",
2124
+ "pptx",
2125
+ "dmg",
2126
+ "pkg",
2127
+ "mp3",
2128
+ "mp4",
2129
+ "wav"
2130
+ ];
2131
+ /**
2132
+ * Per-type defaults. `clicks` and `inputChanges` are noisy, so they default
2133
+ * OFF; `outboundLinks`, `fileDownloads`, and `formSubmissions` are high-signal
2134
+ * and default ON (once autocapture itself is enabled — autocapture is still off
2135
+ * by default). `mediaPlayback` is audience-dependent (only relevant on pages
2136
+ * with native media), so it also defaults OFF.
2137
+ */
2138
+ const AUTOCAPTURE_DEFAULTS = {
2139
+ outboundLinks: true,
2140
+ fileDownloads: true,
2141
+ formSubmissions: true,
2142
+ clicks: false,
2143
+ inputChanges: false,
2144
+ mediaPlayback: false
2145
+ };
2146
+ /** Max characters of captured text before we treat the element as non-atomic. */
2147
+ const MAX_TEXT_LENGTH = 500;
2148
+ /** Truncate captured text to this length in emitted metadata. */
2149
+ const TEXT_SLICE = 200;
2150
+ /**
2151
+ * Playback-progress milestones (as fractions of duration) that emit a discrete
2152
+ * `Media Progress` event the first time playback crosses each one. 100% is the
2153
+ * `ended`/`Media Completed` event, not a progress milestone.
2154
+ */
2155
+ const MEDIA_PROGRESS_THRESHOLDS = [
2156
+ .25,
2157
+ .5,
2158
+ .75
2159
+ ];
2160
+ /** The per-type keys the SDK currently understands (server may carry more). */
2161
+ const AUTOCAPTURE_TYPE_KEYS = [
2162
+ "outboundLinks",
2163
+ "fileDownloads",
2164
+ "formSubmissions",
2165
+ "clicks",
2166
+ "inputChanges",
2167
+ "mediaPlayback"
2168
+ ];
2169
+ /** Any object carrying a `master` or `types` key is the snippet shape. */
2170
+ const isSnippetConfig = (value) => "master" in value || "types" in value;
2171
+ /**
2172
+ * Normalize the three accepted snippet input shapes into a single
2173
+ * `{ master, types }` form that separates master enablement from per-type
2174
+ * overrides:
2175
+ *
2176
+ * - `boolean` → `{ master: <bool>, types: {} }`.
2177
+ * - {@link AutocaptureConfig} (plain object) → `{ master: true, types: <obj> }`
2178
+ * (the direct-`init()` convenience: passing an object means enable).
2179
+ * - {@link AutocaptureSnippetConfig} `{ master, types }` → passed through, with
2180
+ * `master` possibly `undefined` (per-type-only attrs do NOT enable).
2181
+ * - `undefined` → `{ master: undefined, types: {} }`.
2182
+ *
2183
+ * Exported so the override-reporting path (`ac-overrides.ts`) can derive the
2184
+ * EXPLICIT snippet override set from the same normalization the resolver uses.
2185
+ */
2186
+ const normalizeSnippet = (snippet) => {
2187
+ if (typeof snippet === "boolean") return {
2188
+ master: snippet,
2189
+ types: {}
2190
+ };
2191
+ if (typeof snippet !== "object" || snippet === null) return {
2192
+ master: void 0,
2193
+ types: {}
2194
+ };
2195
+ if (isSnippetConfig(snippet)) {
2196
+ var _snippet$types;
2197
+ return {
2198
+ master: snippet.master,
2199
+ types: (_snippet$types = snippet.types) !== null && _snippet$types !== void 0 ? _snippet$types : {}
2200
+ };
2201
+ }
2202
+ return {
2203
+ master: true,
2204
+ types: snippet
2205
+ };
2206
+ };
2207
+ /** Normalize an extension for matching: lowercase + strip a single leading dot. */
2208
+ const normalizeExtension = (ext) => ext.trim().replace(/^\./, "").toLowerCase();
2209
+ /**
2210
+ * Resolve the EFFECTIVE config from three sources, merged **per key**.
2211
+ *
2212
+ * Enablement (master): `snippetMaster ?? server.enabled ?? false`. The snippet's
2213
+ * master is `true`/`false` only when explicitly set (`data-autocapture`, a
2214
+ * plain `init()` object, or `enableAutoClicks`); a bare per-type attribute
2215
+ * leaves it `undefined`, so it can override a type but never flips the master
2216
+ * on. When the master resolves OFF this returns `null` (no listeners attached) —
2217
+ * the fail-safe default when neither snippet nor server enables it.
2218
+ *
2219
+ * Per-type (applies only when the master is on): `snippetType ?? serverType ??
2220
+ * default`. A snippet value wins in either direction (on AND off); else the
2221
+ * server config (delivered on the track response, cached in localStorage); else
2222
+ * {@link AUTOCAPTURE_DEFAULTS}. Only `{ outboundLinks, fileDownloads,
2223
+ * formSubmissions, mediaPlayback }` are read from the server — `clicks` /
2224
+ * `inputChanges` are snippet-only, and any extra server keys (e.g. `notFound`)
2225
+ * are ignored.
2226
+ *
2227
+ * The legacy `enableAutoClicks` flag counts as a snippet-side `clicks: true`.
2228
+ */
2229
+ const resolveEffectiveAutocaptureConfig = (snippet, enableAutoClicks, remote) => {
2230
+ var _ref, _server$types, _server$downloads;
2231
+ const server = remote === null || remote === void 0 ? void 0 : remote.autocapture;
2232
+ const { master: snippetMaster, types: snippetTypes } = normalizeSnippet(snippet);
2233
+ const snippetEnable = snippetMaster !== null && snippetMaster !== void 0 ? snippetMaster : enableAutoClicks ? true : void 0;
2234
+ const serverEnable = typeof (server === null || server === void 0 ? void 0 : server.enabled) === "boolean" ? server.enabled : void 0;
2235
+ if (!((_ref = snippetEnable !== null && snippetEnable !== void 0 ? snippetEnable : serverEnable) !== null && _ref !== void 0 ? _ref : false)) return null;
2236
+ const serverTypes = (_server$types = server === null || server === void 0 ? void 0 : server.types) !== null && _server$types !== void 0 ? _server$types : {};
2237
+ const SERVER_TYPE_KEYS = /* @__PURE__ */ new Set([
2238
+ "outboundLinks",
2239
+ "fileDownloads",
2240
+ "formSubmissions",
2241
+ "mediaPlayback"
2242
+ ]);
2243
+ const resolved = _objectSpread2({}, AUTOCAPTURE_DEFAULTS);
2244
+ for (const key of AUTOCAPTURE_TYPE_KEYS) {
2245
+ const snippetValue = snippetTypes[key];
2246
+ const serverValue = SERVER_TYPE_KEYS.has(key) ? serverTypes[key] : void 0;
2247
+ resolved[key] = typeof snippetValue === "boolean" ? snippetValue : typeof serverValue === "boolean" ? serverValue : AUTOCAPTURE_DEFAULTS[key];
2248
+ }
2249
+ if (enableAutoClicks && snippetTypes.clicks === void 0) resolved.clicks = true;
2250
+ const serverExtensions = server === null || server === void 0 || (_server$downloads = server.downloads) === null || _server$downloads === void 0 ? void 0 : _server$downloads.extensions;
2251
+ const downloadExtensions = Array.isArray(serverExtensions) && serverExtensions.length > 0 ? serverExtensions.map(normalizeExtension).filter(Boolean) : DOWNLOAD_EXTENSIONS;
2252
+ return _objectSpread2(_objectSpread2({}, resolved), {}, { downloadExtensions });
2253
+ };
2254
+ /**
2255
+ * Wire up delegated autocapture listeners. Returns a teardown function that
2256
+ * removes every listener it added. The host passes `track` (already DNT/consent
2257
+ * aware) and `shouldTrack` (re-checked on every event so a later DNT/disable
2258
+ * still short-circuits). Declarative `data-leapio-event` capture is always on
2259
+ * when autocapture is enabled.
2260
+ */
2261
+ const setupAutocapture = (params) => {
2262
+ var _params$doc;
2263
+ const { config, track, shouldTrack } = params;
2264
+ const doc = (_params$doc = params.doc) !== null && _params$doc !== void 0 ? _params$doc : document;
2265
+ const teardowns = [];
2266
+ const onClick = (event) => {
2267
+ if (!shouldTrack()) return;
2268
+ const target = eventTarget(event);
2269
+ if (!target) return;
2270
+ if (target.closest(SENSITIVE_SELECTOR)) return;
2271
+ const declarative = findDeclarativeEvent(target);
2272
+ if (declarative) {
2273
+ track(declarative);
2274
+ return;
2275
+ }
2276
+ if (config.fileDownloads) {
2277
+ const download = buildDownloadEvent(target, doc, config.downloadExtensions);
2278
+ if (download) {
2279
+ track(download);
2280
+ return;
2281
+ }
2282
+ }
2283
+ if (config.outboundLinks) {
2284
+ const outbound = buildOutboundEvent(target, doc);
2285
+ if (outbound) {
2286
+ track(outbound);
2287
+ return;
2288
+ }
2289
+ }
2290
+ if (config.clicks) {
2291
+ const click = buildClickEvent(target);
2292
+ if (click) track(click);
2293
+ }
2294
+ };
2295
+ const onAuxClick = (event) => {
2296
+ if (!(event instanceof MouseEvent) || event.button !== 1) return;
2297
+ if (!shouldTrack()) return;
2298
+ const target = eventTarget(event);
2299
+ if (!target || target.closest(SENSITIVE_SELECTOR)) return;
2300
+ if (config.fileDownloads) {
2301
+ const download = buildDownloadEvent(target, doc, config.downloadExtensions);
2302
+ if (download) {
2303
+ track(download);
2304
+ return;
2305
+ }
2306
+ }
2307
+ if (config.outboundLinks) {
2308
+ const outbound = buildOutboundEvent(target, doc);
2309
+ if (outbound) track(outbound);
2310
+ }
2311
+ };
2312
+ const onSubmit = (event) => {
2313
+ if (!shouldTrack()) return;
2314
+ if (!config.formSubmissions) return;
2315
+ const target = eventTarget(event);
2316
+ const form = target === null || target === void 0 ? void 0 : target.closest("form");
2317
+ if (!form) return;
2318
+ if (form.closest(SENSITIVE_SELECTOR)) return;
2319
+ track(buildFormEvent(form, doc));
2320
+ };
2321
+ const onChange = (event) => {
2322
+ if (!shouldTrack()) return;
2323
+ if (!config.inputChanges) return;
2324
+ const target = eventTarget(event);
2325
+ const field = target === null || target === void 0 ? void 0 : target.closest("input, select, textarea");
2326
+ if (!field) return;
2327
+ if (isSensitiveField(field)) return;
2328
+ track(buildInputEvent(field));
2329
+ };
2330
+ const mediaMilestones = /* @__PURE__ */ new WeakMap();
2331
+ const mediaTarget = (event) => {
2332
+ const target = event.target;
2333
+ return target instanceof HTMLMediaElement ? target : null;
2334
+ };
2335
+ const onMediaPlay = (event) => {
2336
+ if (!shouldTrack()) return;
2337
+ if (!config.mediaPlayback) return;
2338
+ const media = mediaTarget(event);
2339
+ if (!media || isSensitiveMedia(media)) return;
2340
+ track(buildMediaEvent(media, "Media Played"));
2341
+ };
2342
+ const onMediaPause = (event) => {
2343
+ if (!shouldTrack()) return;
2344
+ if (!config.mediaPlayback) return;
2345
+ const media = mediaTarget(event);
2346
+ if (!media || isSensitiveMedia(media)) return;
2347
+ if (media.ended) return;
2348
+ track(buildMediaEvent(media, "Media Paused"));
2349
+ };
2350
+ const onMediaTimeUpdate = (event) => {
2351
+ if (!shouldTrack()) return;
2352
+ if (!config.mediaPlayback) return;
2353
+ const media = mediaTarget(event);
2354
+ if (!media || isSensitiveMedia(media)) return;
2355
+ const duration = media.duration;
2356
+ const currentTime = media.currentTime;
2357
+ if (!Number.isFinite(duration) || duration <= 0) return;
2358
+ let crossed = mediaMilestones.get(media);
2359
+ if (!crossed) {
2360
+ crossed = /* @__PURE__ */ new Set();
2361
+ mediaMilestones.set(media, crossed);
2362
+ }
2363
+ if (currentTime <= 0 && crossed.size > 0) crossed.clear();
2364
+ const percent = currentTime / duration;
2365
+ for (const threshold of MEDIA_PROGRESS_THRESHOLDS) if (percent >= threshold && !crossed.has(threshold)) {
2366
+ crossed.add(threshold);
2367
+ track(buildMediaEvent(media, "Media Progress", String(Math.round(threshold * 100))));
2368
+ }
2369
+ };
2370
+ const onMediaEnded = (event) => {
2371
+ if (!shouldTrack()) return;
2372
+ if (!config.mediaPlayback) return;
2373
+ const media = mediaTarget(event);
2374
+ if (!media || isSensitiveMedia(media)) return;
2375
+ mediaMilestones.delete(media);
2376
+ track(buildMediaEvent(media, "Media Completed", "100"));
2377
+ };
2378
+ doc.addEventListener("click", onClick, true);
2379
+ teardowns.push(() => doc.removeEventListener("click", onClick, true));
2380
+ if (config.fileDownloads || config.outboundLinks) {
2381
+ doc.addEventListener("auxclick", onAuxClick, true);
2382
+ teardowns.push(() => doc.removeEventListener("auxclick", onAuxClick, true));
2383
+ }
2384
+ if (config.formSubmissions) {
2385
+ doc.addEventListener("submit", onSubmit, true);
2386
+ teardowns.push(() => doc.removeEventListener("submit", onSubmit, true));
2387
+ }
2388
+ if (config.inputChanges) {
2389
+ doc.addEventListener("change", onChange, true);
2390
+ teardowns.push(() => doc.removeEventListener("change", onChange, true));
2391
+ }
2392
+ if (config.mediaPlayback) {
2393
+ doc.addEventListener("play", onMediaPlay, true);
2394
+ doc.addEventListener("pause", onMediaPause, true);
2395
+ doc.addEventListener("timeupdate", onMediaTimeUpdate, true);
2396
+ doc.addEventListener("ended", onMediaEnded, true);
2397
+ teardowns.push(() => {
2398
+ doc.removeEventListener("play", onMediaPlay, true);
2399
+ doc.removeEventListener("pause", onMediaPause, true);
2400
+ doc.removeEventListener("timeupdate", onMediaTimeUpdate, true);
2401
+ doc.removeEventListener("ended", onMediaEnded, true);
2402
+ });
2403
+ }
2404
+ return () => {
2405
+ for (const teardown of teardowns) teardown();
2406
+ };
2407
+ };
2408
+ /** Narrow an event's target to an Element, or null for non-element targets. */
2409
+ const eventTarget = (event) => {
2410
+ const target = event.target;
2411
+ return target instanceof Element ? target : null;
2412
+ };
2413
+ /**
2414
+ * Declarative `data-leapio-event` capture. Walks to the nearest ancestor (or
2415
+ * self) carrying the attribute and collects `data-leapio-prop-*` props into
2416
+ * metadata. Supersedes the bare `lgspt-` classname approach.
2417
+ */
2418
+ const findDeclarativeEvent = (target) => {
2419
+ var _el$getAttribute;
2420
+ const el = target.closest(`[${DECLARATIVE_EVENT_ATTR}],[${LEGACY_DECLARATIVE_EVENT_ATTR}]`);
2421
+ if (!(el instanceof HTMLElement)) return null;
2422
+ const eventName = (_el$getAttribute = el.getAttribute("data-leapio-event")) !== null && _el$getAttribute !== void 0 ? _el$getAttribute : el.getAttribute(LEGACY_DECLARATIVE_EVENT_ATTR);
2423
+ if (!eventName) return null;
2424
+ const metadata = {};
2425
+ for (const attr of Array.from(el.attributes)) {
2426
+ const prefix = [DECLARATIVE_PROP_PREFIX, LEGACY_DECLARATIVE_PROP_PREFIX].find((candidate) => attr.name.startsWith(candidate));
2427
+ if (prefix) {
2428
+ const key = attr.name.slice(prefix.length);
2429
+ if (key) metadata[key] = attr.value;
2430
+ }
2431
+ }
2432
+ return {
2433
+ event: eventName,
2434
+ metadata
2435
+ };
2436
+ };
2437
+ /**
2438
+ * Outbound-link capture. Fires only when the closest `a[href]` resolves to a
2439
+ * different host than the current page, and is not a same-page anchor / mailto
2440
+ * / tel link.
2441
+ */
2442
+ const buildOutboundEvent = (target, doc) => {
2443
+ var _doc$location, _elementText;
2444
+ const resolved = resolveNavigableAnchor(target, doc);
2445
+ if (!resolved) return null;
2446
+ const { anchor, url } = resolved;
2447
+ if (!url.host || url.host === ((_doc$location = doc.location) === null || _doc$location === void 0 ? void 0 : _doc$location.host)) return null;
2448
+ return {
2449
+ event: "Outbound Link",
2450
+ metadata: {
2451
+ href: anchor.href || anchor.getAttribute("href") || void 0,
2452
+ text: (_elementText = elementText(anchor)) === null || _elementText === void 0 ? void 0 : _elementText.slice(0, TEXT_SLICE)
2453
+ }
2454
+ };
2455
+ };
2456
+ const resolveNavigableAnchor = (target, doc) => {
2457
+ var _anchor$getAttribute;
2458
+ const anchor = target.closest("a[href]");
2459
+ if (!(anchor instanceof HTMLAnchorElement)) return null;
2460
+ const rawHref = (_anchor$getAttribute = anchor.getAttribute("href")) !== null && _anchor$getAttribute !== void 0 ? _anchor$getAttribute : "";
2461
+ if (!rawHref) return null;
2462
+ if (rawHref.startsWith("#") || /^(mailto:|tel:|sms:|javascript:)/i.test(rawHref)) return null;
2463
+ let url;
2464
+ try {
2465
+ var _doc$location2;
2466
+ url = new URL(anchor.href || rawHref, (_doc$location2 = doc.location) === null || _doc$location2 === void 0 ? void 0 : _doc$location2.href);
2467
+ } catch (_unused) {
2468
+ return null;
2469
+ }
2470
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
2471
+ return {
2472
+ anchor,
2473
+ url
2474
+ };
2475
+ };
2476
+ /**
2477
+ * File-download capture. Fires on a click of an `a[href]` that either carries a
2478
+ * `download` attribute or whose resolved path ends in a known download
2479
+ * extension. The extension allowlist is the effective config's
2480
+ * `downloadExtensions` (server-provided list when present, else the built-in
2481
+ * {@link DOWNLOAD_EXTENSIONS}); both the configured extensions and the URL's
2482
+ * extension are normalized (lowercase, no leading dot) for matching. Pure
2483
+ * metadata — `{ href, fileName, fileExtension }`, no PII. Same-page anchors and
2484
+ * non-navigational schemes (mailto/tel/sms/javascript) are never downloads.
2485
+ */
2486
+ const buildDownloadEvent = (target, doc, extensions) => {
2487
+ const resolved = resolveNavigableAnchor(target, doc);
2488
+ if (!resolved) return null;
2489
+ const { anchor, url } = resolved;
2490
+ const hasDownloadAttr = anchor.hasAttribute("download");
2491
+ const fileExtension = fileExtensionFromPath(url.pathname);
2492
+ const isKnownExtension = !!fileExtension && extensions.some((ext) => normalizeExtension(ext) === fileExtension);
2493
+ if (!hasDownloadAttr && !isKnownExtension) return null;
2494
+ return {
2495
+ event: "File Downloaded",
2496
+ metadata: {
2497
+ href: anchor.href || anchor.getAttribute("href") || void 0,
2498
+ fileName: fileNameFromPath(url.pathname),
2499
+ fileExtension
2500
+ }
2501
+ };
2502
+ };
2503
+ /** Form-submission capture. Metadata only — field values are never read. */
2504
+ const buildFormEvent = (form, doc) => ({
2505
+ event: "Form Submitted",
2506
+ metadata: {
2507
+ id: attr(form, "id"),
2508
+ name: attr(form, "name"),
2509
+ action: formAction(form, doc)
2510
+ }
2511
+ });
2512
+ const formAction = (form, doc) => {
2513
+ const rawAction = attr(form, "action");
2514
+ if (!rawAction) return void 0;
2515
+ try {
2516
+ var _doc$location3;
2517
+ const resolved = new URL(rawAction, (_doc$location3 = doc.location) === null || _doc$location3 === void 0 ? void 0 : _doc$location3.href);
2518
+ return `${resolved.protocol}//${resolved.host}${resolved.pathname}`;
2519
+ } catch (_unused2) {
2520
+ return rawAction.split(/[?#]/)[0] || void 0;
2521
+ }
2522
+ };
2523
+ /**
2524
+ * Generic click capture (off by default). Targets the nearest interactive
2525
+ * element. Skips elements whose visible text exceeds {@link MAX_TEXT_LENGTH}
2526
+ * (the fixed length-guard: compares `.length`, not the string itself).
2527
+ */
2528
+ const buildClickEvent = (target) => {
2529
+ var _target$closest;
2530
+ const el = (_target$closest = target.closest("a, button, [role=button]")) !== null && _target$closest !== void 0 ? _target$closest : target;
2531
+ const text = el.matches("input, textarea, select, optgroup, option") ? void 0 : elementText(el);
2532
+ if (text && text.length > MAX_TEXT_LENGTH) return null;
2533
+ return {
2534
+ event: "Click",
2535
+ metadata: {
2536
+ tag: el.tagName,
2537
+ id: attr(el, "id"),
2538
+ text: text === null || text === void 0 ? void 0 : text.slice(0, TEXT_SLICE),
2539
+ href: el instanceof HTMLAnchorElement ? el.href || void 0 : void 0
2540
+ }
2541
+ };
2542
+ };
2543
+ /** Input-change capture. Metadata only — the value is NEVER captured. */
2544
+ const buildInputEvent = (field) => ({
2545
+ event: "Input Changed",
2546
+ metadata: {
2547
+ name: attr(field, "name"),
2548
+ type: fieldType(field)
2549
+ }
2550
+ });
2551
+ /**
2552
+ * Native `<video>`/`<audio>` engagement capture (metadata only — never a
2553
+ * recording). `percent` is set only for `Media Progress` (the milestone) and
2554
+ * `Media Completed` (`"100"`). `duration`/`current_time` are rounded whole
2555
+ * seconds; `duration` is omitted for non-finite values (e.g. live streams). The
2556
+ * `src` is the element `id` when present, else the basename of `currentSrc`/
2557
+ * `src` (filename only — query string dropped to avoid leaking URLs/PII).
2558
+ */
2559
+ const buildMediaEvent = (media, event, percent) => {
2560
+ const duration = media.duration;
2561
+ return {
2562
+ event,
2563
+ metadata: {
2564
+ media_type: media instanceof HTMLVideoElement ? "video" : "audio",
2565
+ src: mediaSource(media),
2566
+ title: mediaTitle(media),
2567
+ percent,
2568
+ duration: Number.isFinite(duration) ? String(Math.round(duration)) : void 0,
2569
+ current_time: String(Math.round(media.currentTime))
2570
+ }
2571
+ };
2572
+ };
2573
+ /**
2574
+ * Whether a media element is flagged sensitive and must be skipped. Mirrors the
2575
+ * input opt-out: the element itself or any ancestor carrying
2576
+ * `data-leapio-sensitive`.
2577
+ */
2578
+ const isSensitiveMedia = (media) => media.closest(SENSITIVE_SELECTOR) !== null;
2579
+ /**
2580
+ * Stable identifier for a media element: its `id` when present, else the
2581
+ * basename (filename only, query string dropped) of `currentSrc`/`src`. Returns
2582
+ * undefined when there is no usable source — never the full URL, to avoid
2583
+ * leaking PII in query params.
2584
+ */
2585
+ const mediaSource = (media) => {
2586
+ const id = attr(media, "id");
2587
+ if (id) return id;
2588
+ const raw = media.currentSrc || media.src;
2589
+ if (!raw) return void 0;
2590
+ return urlBasename(raw);
2591
+ };
2592
+ /**
2593
+ * Best-effort human label for a media element: `aria-label`, else the `title`
2594
+ * attribute, else the nearest `<label>`/`<figcaption>` text, else the source
2595
+ * filename. Truncated to {@link TEXT_SLICE}. Undefined when nothing is found.
2596
+ */
2597
+ const mediaTitle = (media) => {
2598
+ var _media$closest;
2599
+ const ariaLabel = attr(media, "aria-label");
2600
+ if (ariaLabel) return ariaLabel.slice(0, TEXT_SLICE);
2601
+ const title = attr(media, "title");
2602
+ if (title) return title.slice(0, TEXT_SLICE);
2603
+ const figcaption = (_media$closest = media.closest("figure")) === null || _media$closest === void 0 ? void 0 : _media$closest.querySelector("figcaption");
2604
+ const captionText = figcaption ? elementText(figcaption) : void 0;
2605
+ if (captionText) return captionText.slice(0, TEXT_SLICE);
2606
+ const label = media.closest("label");
2607
+ const labelText = label ? elementText(label) : void 0;
2608
+ if (labelText) return labelText.slice(0, TEXT_SLICE);
2609
+ const raw = media.currentSrc || media.src;
2610
+ return raw ? urlBasename(raw) : void 0;
2611
+ };
2612
+ /**
2613
+ * Filename (last path segment) of a URL, with the query string and hash
2614
+ * dropped. Resolves relative to the page so jsdom and the browser agree.
2615
+ * Returns undefined when there is no meaningful segment.
2616
+ */
2617
+ const urlBasename = (raw) => {
2618
+ try {
2619
+ var _document$location;
2620
+ const url = new URL(raw, (_document$location = document.location) === null || _document$location === void 0 ? void 0 : _document$location.href);
2621
+ return fileNameFromPath(url.pathname);
2622
+ } catch (_unused3) {
2623
+ const withoutQuery = raw.split(/[?#]/)[0];
2624
+ return fileNameFromPath(withoutQuery);
2625
+ }
2626
+ };
2627
+ /**
2628
+ * Whether an input/select/textarea is sensitive and must be skipped. Covers
2629
+ * password fields, one-time codes, credit-card autofill, stored credentials,
2630
+ * and any element (or ancestor) marked `data-leapio-sensitive`.
2631
+ */
2632
+ const isSensitiveField = (field) => {
2633
+ var _field$getAttribute, _field$getAttribute2;
2634
+ if (field.closest(SENSITIVE_SELECTOR)) return true;
2635
+ if (((_field$getAttribute = field.getAttribute("type")) !== null && _field$getAttribute !== void 0 ? _field$getAttribute : "").toLowerCase() === "password") return true;
2636
+ const autocomplete = ((_field$getAttribute2 = field.getAttribute("autocomplete")) !== null && _field$getAttribute2 !== void 0 ? _field$getAttribute2 : "").toLowerCase();
2637
+ if (autocomplete) {
2638
+ if (autocomplete.includes("one-time-code") || autocomplete.includes("current-password") || autocomplete.includes("new-password") || autocomplete.includes("cc-")) return true;
2639
+ }
2640
+ return false;
2641
+ };
2642
+ /**
2643
+ * Last path segment of a URL pathname (the file name), decoded and trimmed of
2644
+ * trailing slashes. Returns undefined when there is no meaningful segment.
2645
+ */
2646
+ const fileNameFromPath = (pathname) => {
2647
+ const segments = pathname.split("/").filter(Boolean);
2648
+ const last = segments[segments.length - 1];
2649
+ if (!last) return void 0;
2650
+ try {
2651
+ return decodeURIComponent(last);
2652
+ } catch (_unused4) {
2653
+ return last;
2654
+ }
2655
+ };
2656
+ /**
2657
+ * Lower-cased extension (without the dot) of a URL pathname's file name, or
2658
+ * undefined when there is no dotted extension.
2659
+ */
2660
+ const fileExtensionFromPath = (pathname) => {
2661
+ const fileName = fileNameFromPath(pathname);
2662
+ if (!fileName) return void 0;
2663
+ const dot = fileName.lastIndexOf(".");
2664
+ if (dot <= 0 || dot === fileName.length - 1) return void 0;
2665
+ return fileName.slice(dot + 1).toLowerCase();
2666
+ };
2667
+ const fieldType = (field) => {
2668
+ const explicit = field.getAttribute("type");
2669
+ if (explicit) return explicit;
2670
+ return field.tagName.toLowerCase();
2671
+ };
2672
+ const attr = (el, name) => {
2673
+ const value = el.getAttribute(name);
2674
+ return value === null || value === "" ? void 0 : value;
2675
+ };
2676
+ /** Visible text of an element, trimmed. Returns undefined when empty. */
2677
+ const elementText = (el) => {
2678
+ var _ref2, _ref3;
2679
+ const text = ((_ref2 = (_ref3 = el instanceof HTMLElement ? el.innerText : null) !== null && _ref3 !== void 0 ? _ref3 : el.textContent) !== null && _ref2 !== void 0 ? _ref2 : "").trim();
2680
+ return text.length ? text : void 0;
2681
+ };
2682
+ //#endregion
2683
+ //#region src/dnt.ts
2684
+ const hasExplicitDNT = () => {
2685
+ const DNT_KEY = "leapio_dnt";
2686
+ const LEGACY_DNT_KEY = "lgspt_dnt";
2687
+ const urlSearchParams = new URLSearchParams(window.location.search);
2688
+ const params = Object.fromEntries(urlSearchParams.entries());
2689
+ if (params.dnt === "false" || params.dnt === "0") {
2690
+ localStorage.removeItem(DNT_KEY);
2691
+ localStorage.removeItem(LEGACY_DNT_KEY);
2692
+ return false;
2693
+ }
2694
+ const queryWithDNT = typeof params.dnt !== "undefined";
2695
+ const disableTracking = queryWithDNT || localStorage.getItem(DNT_KEY) === "1" || localStorage.getItem(LEGACY_DNT_KEY) === "1";
2696
+ if (queryWithDNT) localStorage.setItem(DNT_KEY, "1");
2697
+ return disableTracking;
2698
+ };
2699
+ const hasBrowserDNT = () => {
2700
+ return navigator.doNotTrack === "1" || navigator.doNotTrack === "yes" || window.doNotTrack === "1";
2701
+ };
2702
+ const isAutomatedBrowser = () => navigator.webdriver === true;
2703
+ //#endregion
2704
+ //#region src/engagement.ts
2705
+ const ENGAGEMENT_MODES = [
2706
+ "off",
2707
+ "leave",
2708
+ "heartbeat"
2709
+ ];
2710
+ const HEARTBEAT_INTERVAL_MS = 6e4;
2711
+ const MIN_REPORT_MS = 1e3;
2712
+ const isEngagementMode = (value) => ENGAGEMENT_MODES.includes(value);
2713
+ /**
2714
+ * Count visible time on the current page and report it per `mode`. `send`
2715
+ * must capture the page context synchronously (the leave report for an SPA
2716
+ * route change fires before the SDK advances to the new url).
2717
+ */
2718
+ const setupEngagement = (opts) => {
2719
+ var _opts$now;
2720
+ const now = (_opts$now = opts.now) !== null && _opts$now !== void 0 ? _opts$now : Date.now;
2721
+ const isVisible = () => document.visibilityState === "visible";
2722
+ let visibleSince = isVisible() ? now() : null;
2723
+ let unreportedMs = 0;
2724
+ let leaves = 0;
2725
+ let heartbeats = 0;
2726
+ const collect = () => {
2727
+ if (visibleSince !== null) {
2728
+ const at = now();
2729
+ unreportedMs += at - visibleSince;
2730
+ visibleSince = isVisible() ? at : null;
2731
+ }
2732
+ };
2733
+ const report = (event) => {
2734
+ if (unreportedMs < MIN_REPORT_MS) return false;
2735
+ opts.send(event, { engaged_seconds: Math.round(unreportedMs / 1e3) });
2736
+ unreportedMs = 0;
2737
+ return true;
2738
+ };
2739
+ const leave = () => {
2740
+ collect();
2741
+ if (leaves < 10 && report("Page Leave")) leaves++;
2742
+ };
2743
+ const onVisibilityChange = () => {
2744
+ if (isVisible()) {
2745
+ if (visibleSince === null) visibleSince = now();
2746
+ } else leave();
2747
+ };
2748
+ document.addEventListener("visibilitychange", onVisibilityChange);
2749
+ window.addEventListener("pagehide", leave);
2750
+ const timer = opts.mode === "heartbeat" ? setInterval(() => {
2751
+ if (!isVisible() || heartbeats >= 30) return;
2752
+ collect();
2753
+ if (report("Page Heartbeat")) heartbeats++;
2754
+ }, HEARTBEAT_INTERVAL_MS) : null;
2755
+ return {
2756
+ pageChanged: () => {
2757
+ leave();
2758
+ leaves = 0;
2759
+ heartbeats = 0;
2760
+ unreportedMs = 0;
2761
+ visibleSince = isVisible() ? now() : null;
2762
+ },
2763
+ teardown: () => {
2764
+ document.removeEventListener("visibilitychange", onVisibilityChange);
2765
+ window.removeEventListener("pagehide", leave);
2766
+ if (timer !== null) clearInterval(timer);
2767
+ }
2768
+ };
2769
+ };
2770
+ //#endregion
2771
+ //#region src/payment-links.ts
2772
+ const STRIPE_PAYMENT_LINK_HOST = "buy.stripe.com";
2773
+ const CLIENT_REFERENCE_PARAM = "client_reference_id";
2774
+ /** The href with the anonymous id added, or null when it should be left as is. */
2775
+ const tagPaymentLinkHref = (href, anonymousId) => {
2776
+ let url;
2777
+ try {
2778
+ url = new URL(href);
2779
+ } catch (_unused) {
2780
+ return null;
2781
+ }
2782
+ if (url.hostname !== STRIPE_PAYMENT_LINK_HOST) return null;
2783
+ if (url.searchParams.has(CLIENT_REFERENCE_PARAM)) return null;
2784
+ url.searchParams.set(CLIENT_REFERENCE_PARAM, anonymousId);
2785
+ return url.toString();
2786
+ };
2787
+ const ownTags = /* @__PURE__ */ new WeakMap();
2788
+ /**
2789
+ * Tag Stripe Payment Link anchors on press. `pointerdown` runs before the
2790
+ * context menu, so "open in new tab" gets the tagged href too; `click` and
2791
+ * `auxclick` cover keyboard activation and middle-click. Returns a teardown.
2792
+ */
2793
+ const listenForPaymentLinks = (opts) => {
2794
+ const onPress = (event) => {
2795
+ var _target$closest;
2796
+ if (!opts.shouldTag()) return;
2797
+ const target = event.target;
2798
+ const anchor = target === null || target === void 0 || (_target$closest = target.closest) === null || _target$closest === void 0 ? void 0 : _target$closest.call(target, "a[href]");
2799
+ const anonymousId = opts.getAnonymousId();
2800
+ if (!anchor || !anonymousId) return;
2801
+ const own = ownTags.get(anchor);
2802
+ const from = own && own.to === anchor.href ? own.from : anchor.href;
2803
+ const tagged = tagPaymentLinkHref(from, anonymousId);
2804
+ if (tagged) {
2805
+ anchor.href = tagged;
2806
+ ownTags.set(anchor, {
2807
+ from,
2808
+ to: anchor.href
2809
+ });
2810
+ }
2811
+ };
2812
+ const events = [
2813
+ "pointerdown",
2814
+ "click",
2815
+ "auxclick"
2816
+ ];
2817
+ for (const name of events) document.addEventListener(name, onPress, true);
2818
+ return () => {
2819
+ for (const name of events) document.removeEventListener(name, onPress, true);
2820
+ };
2821
+ };
2822
+ //#endregion
2823
+ //#region src/privacy-signals.ts
2824
+ /**
2825
+ * Read GPC. Returns `true` only when the browser explicitly exposes the signal
2826
+ * as enabled; `undefined` when the API is absent (so callers can distinguish
2827
+ * "not present" from "present and false").
2828
+ */
2829
+ const detectGpc = () => {
2830
+ try {
2831
+ if (typeof navigator === "undefined") return void 0;
2832
+ const value = navigator.globalPrivacyControl;
2833
+ if (typeof value === "boolean") return value;
2834
+ return;
2835
+ } catch (_unused) {
2836
+ return;
2837
+ }
2838
+ };
2839
+ const getGppApi = () => {
2840
+ try {
2841
+ if (typeof window === "undefined") return void 0;
2842
+ const api = window.__gpp;
2843
+ return typeof api === "function" ? api : void 0;
2844
+ } catch (_unused2) {
2845
+ return;
2846
+ }
2847
+ };
2848
+ /**
2849
+ * Synchronously probe for GPP via `__gpp('ping', cb)`. The IAB stub invokes the
2850
+ * callback synchronously, so the result is available immediately in practice;
2851
+ * if a host implementation is async, the synchronous read simply returns
2852
+ * "not detected" and a later `subscribeGpp` event updates state.
2853
+ */
2854
+ const detectGpp = () => {
2855
+ const api = getGppApi();
2856
+ if (!api) return { detected: false };
2857
+ let detected = false;
2858
+ let gppString;
2859
+ try {
2860
+ api("ping", (data, success) => {
2861
+ if (!success || !data || typeof data !== "object") return;
2862
+ detected = true;
2863
+ const ping = data;
2864
+ if (typeof ping.gppString === "string" && ping.gppString.length > 0) gppString = ping.gppString;
2865
+ });
2866
+ } catch (_unused3) {
2867
+ detected = true;
2868
+ }
2869
+ return {
2870
+ detected,
2871
+ gppString
2872
+ };
2873
+ };
2874
+ /**
2875
+ * Subscribe to GPP section/string changes via `__gpp('addEventListener', cb)`.
2876
+ * Calls `onChange` whenever the GPP string is (re)computed. Returns an
2877
+ * unsubscribe function (best-effort `removeEventListener`).
2878
+ */
2879
+ const subscribeGpp = (onChange) => {
2880
+ const api = getGppApi();
2881
+ if (!api) return () => {};
2882
+ let listenerId;
2883
+ try {
2884
+ api("addEventListener", (data, success) => {
2885
+ var _event$pingData;
2886
+ if (!success || !data || typeof data !== "object") return;
2887
+ const event = data;
2888
+ if (typeof event.listenerId !== "undefined") listenerId = event.listenerId;
2889
+ onChange({
2890
+ detected: true,
2891
+ gppString: typeof ((_event$pingData = event.pingData) === null || _event$pingData === void 0 ? void 0 : _event$pingData.gppString) === "string" ? event.pingData.gppString : typeof event.gppString === "string" ? event.gppString : void 0
2892
+ });
2893
+ });
2894
+ } catch (_unused4) {
2895
+ return () => {};
2896
+ }
2897
+ return () => {
2898
+ try {
2899
+ if (typeof listenerId !== "undefined") api("removeEventListener", () => {}, listenerId);
2900
+ } catch (_unused5) {}
2901
+ };
2902
+ };
2903
+ /**
2904
+ * Detect all currently-readable browser privacy signals as a `PrivacySignals`
2905
+ * fragment suitable for merging into a consent snapshot. Only signals that are
2906
+ * actually present are included.
2907
+ */
2908
+ const detectPrivacySignals = () => {
2909
+ const signals = {};
2910
+ if (detectGpc() === true) signals.gpc = true;
2911
+ const gpp = detectGpp();
2912
+ if (gpp.detected) {
2913
+ signals.gppDetected = true;
2914
+ if (gpp.gppString) signals.gppString = gpp.gppString;
2915
+ }
2916
+ return signals;
2917
+ };
2918
+ //#endregion
2919
+ //#region src/super-properties.ts
2920
+ var SuperProperties = class {
2921
+ constructor(sdkConfig) {
2922
+ this.sdkConfig = sdkConfig;
2923
+ this.PROPS_COOKIE = "leapio_p";
2924
+ this.LEGACY_PROPS_COOKIE = "lgspt_p";
2925
+ this.expiryDays = 30;
2926
+ try {
2927
+ var _getCookie;
2928
+ const cookie = (_getCookie = getCookie(this.PROPS_COOKIE)) !== null && _getCookie !== void 0 ? _getCookie : getCookie(this.LEGACY_PROPS_COOKIE);
2929
+ this.properties = cookie ? JSON.parse(this.UnicodeDecodeB64(cookie)) : {};
2930
+ } catch (_unused) {
2931
+ this.properties = {};
2932
+ }
2933
+ }
2934
+ register(newProps, days) {
2935
+ this.properties = _objectSpread2(_objectSpread2({}, this.properties), newProps);
2936
+ if (typeof days !== "undefined") this.expiryDays = days;
2937
+ this._save();
2938
+ }
2939
+ unregister(property) {
2940
+ delete this.properties[property];
2941
+ this._save();
2942
+ }
2943
+ clear() {
2944
+ var _this$sdkConfig$cooki, _this$sdkConfig$cooki2;
2945
+ this.properties = {};
2946
+ eraseCookie(this.PROPS_COOKIE, (_this$sdkConfig$cooki = this.sdkConfig.cookieDomain) !== null && _this$sdkConfig$cooki !== void 0 ? _this$sdkConfig$cooki : null);
2947
+ eraseCookie(this.LEGACY_PROPS_COOKIE, (_this$sdkConfig$cooki2 = this.sdkConfig.cookieDomain) !== null && _this$sdkConfig$cooki2 !== void 0 ? _this$sdkConfig$cooki2 : null);
2948
+ }
2949
+ getProperties() {
2950
+ return this.properties;
2951
+ }
2952
+ _save() {
2953
+ var _this$sdkConfig$cooki3;
2954
+ if (this.sdkConfig.cookiesDisabled) return;
2955
+ setCookie({
2956
+ name: this.PROPS_COOKIE,
2957
+ value: this.b64EncodeUnicode(JSON.stringify(this.properties)),
2958
+ expiresInSeconds: this.expiryDays * ONE_DAY_IN_SECONDS,
2959
+ domain: (_this$sdkConfig$cooki3 = this.sdkConfig.cookieDomain) !== null && _this$sdkConfig$cooki3 !== void 0 ? _this$sdkConfig$cooki3 : null
2960
+ });
2961
+ }
2962
+ b64EncodeUnicode(str) {
2963
+ return btoa(encodeURIComponent(str));
2964
+ }
2965
+ UnicodeDecodeB64(str) {
2966
+ return decodeURIComponent(atob(str));
2967
+ }
2968
+ };
2969
+ //#endregion
2970
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/objectWithoutPropertiesLoose.js
2971
+ function _objectWithoutPropertiesLoose(r, e) {
2972
+ if (null == r) return {};
2973
+ var t = {};
2974
+ for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
2975
+ if (e.includes(n)) continue;
2976
+ t[n] = r[n];
2977
+ }
2978
+ return t;
2979
+ }
2980
+ //#endregion
2981
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/objectWithoutProperties.js
2982
+ function _objectWithoutProperties(e, t) {
2983
+ if (null == e) return {};
2984
+ var o, r, i = _objectWithoutPropertiesLoose(e, t);
2985
+ if (Object.getOwnPropertySymbols) {
2986
+ var s = Object.getOwnPropertySymbols(e);
2987
+ for (r = 0; r < s.length; r++) o = s[r], t.includes(o) || {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]);
2988
+ }
2989
+ return i;
2990
+ }
2991
+ //#endregion
2992
+ //#region src/index.ts
2993
+ const _excluded = ["userId"];
2994
+ const _excluded2 = [
2995
+ "currency",
2996
+ "event",
2997
+ "channel",
2998
+ "userId"
2999
+ ];
3000
+ const DEFAULT_CONFIG = {
3001
+ cookiesDisabled: false,
3002
+ enableAutoPageviews: true,
3003
+ enableAutoClicks: false,
3004
+ stickyCampaigns: false,
3005
+ consentBehavior: "notRequired",
3006
+ stripQueryParams: false,
3007
+ legacySpaDetection: false,
3008
+ tagPaymentLinks: true,
3009
+ trackAutomatedBrowsers: false
3010
+ };
3011
+ const MAX_PENDING_EVENTS = 100;
3012
+ /** Default reserved event name marking an event as revenue (server-default). */
3013
+ const DEFAULT_REVENUE_EVENT_NAME = "Payment";
3014
+ const Leapio = () => {
3015
+ let sdkConfig;
3016
+ let disableTracking;
3017
+ let getPageViewPayload;
3018
+ let currentUrl;
3019
+ let currentRef;
3020
+ let identifiedUserId = null;
3021
+ let lastIdentityVerification = null;
3022
+ let anonymousId;
3023
+ let superProperties;
3024
+ let campaign;
3025
+ let consentStore;
3026
+ let consentSourceRegistry = null;
3027
+ let pendingEvents = [];
3028
+ let teardownAutocapture = null;
3029
+ let teardownClassClicks = null;
3030
+ let teardownSpaPageviews = null;
3031
+ let teardownPaymentLinks = null;
3032
+ let engagementTracker = null;
3033
+ let appliedEngagementMode;
3034
+ let appliedAutocaptureKey;
3035
+ let detectedSignals = {};
3036
+ let lastPostedConsentKey = null;
3037
+ let teardownReadyListener = null;
3038
+ let teardownGpp = null;
3039
+ const getStorageOpts = () => {
3040
+ var _sdkConfig$cookieDoma, _sdkConfig$cookieExpi;
3041
+ return {
3042
+ cookiesDisabled: !!(sdkConfig === null || sdkConfig === void 0 ? void 0 : sdkConfig.cookiesDisabled),
3043
+ cookieDomain: (_sdkConfig$cookieDoma = sdkConfig === null || sdkConfig === void 0 ? void 0 : sdkConfig.cookieDomain) !== null && _sdkConfig$cookieDoma !== void 0 ? _sdkConfig$cookieDoma : null,
3044
+ cookieExpirationInSeconds: (_sdkConfig$cookieExpi = sdkConfig === null || sdkConfig === void 0 ? void 0 : sdkConfig.cookieExpirationInSeconds) !== null && _sdkConfig$cookieExpi !== void 0 ? _sdkConfig$cookieExpi : ONE_YEAR_IN_SECONDS
3045
+ };
3046
+ };
3047
+ /** Whether the SDK should hold events until analytics is explicitly granted. */
3048
+ const isExpressMode = () => (sdkConfig === null || sdkConfig === void 0 ? void 0 : sdkConfig.consentBehavior) === "express";
3049
+ /** Whether the SDK sends until analytics is explicitly denied. */
3050
+ const isImpliedMode = () => (sdkConfig === null || sdkConfig === void 0 ? void 0 : sdkConfig.consentBehavior) === "implied";
3051
+ /** Whether an event captured right now would be sent (not queued/dropped). */
3052
+ const canSendNow = () => {
3053
+ var _consentStore$get;
3054
+ const analytics = consentStore === null || consentStore === void 0 || (_consentStore$get = consentStore.get()) === null || _consentStore$get === void 0 ? void 0 : _consentStore$get.categories.analytics;
3055
+ if (isExpressMode()) return analytics === true;
3056
+ if (isImpliedMode()) return analytics !== false;
3057
+ return true;
3058
+ };
3059
+ const consentPostKey = (snapshot) => snapshot ? JSON.stringify({
3060
+ c: snapshot.categories,
3061
+ s: snapshot.signals,
3062
+ src: snapshot.source
3063
+ }) : null;
3064
+ /** Union browser-detected GPC/GPP into the authoritative snapshot. */
3065
+ const mergeDetectedSignalsIntoStore = () => {
3066
+ const current = consentStore === null || consentStore === void 0 ? void 0 : consentStore.get();
3067
+ if (!current || Object.keys(detectedSignals).length === 0) return;
3068
+ const merged = _objectSpread2(_objectSpread2({}, current.signals), detectedSignals);
3069
+ if (JSON.stringify(merged) !== JSON.stringify(current.signals)) consentStore.mergeSnapshot({ signals: merged }, current.source);
3070
+ };
3071
+ /** POST the authoritative snapshot to /consent, deduped on its wire key. */
3072
+ const pushConsentIfChanged = async (verification) => {
3073
+ var _ref;
3074
+ const snapshot = consentStore === null || consentStore === void 0 ? void 0 : consentStore.get();
3075
+ if (!snapshot) return {
3076
+ ok: true,
3077
+ reason: "no_snapshot"
3078
+ };
3079
+ const key = consentPostKey(snapshot);
3080
+ if (key === lastPostedConsentKey) return {
3081
+ ok: true,
3082
+ reason: "unchanged"
3083
+ };
3084
+ const prevKey = lastPostedConsentKey;
3085
+ lastPostedConsentKey = key;
3086
+ const result = await postConsent(sdkConfig, {
3087
+ anonymousId,
3088
+ userId: identifiedUserId,
3089
+ snapshot,
3090
+ identityVerification: (_ref = verification !== null && verification !== void 0 ? verification : lastIdentityVerification) !== null && _ref !== void 0 ? _ref : void 0
3091
+ });
3092
+ if (!result.ok && lastPostedConsentKey === key) lastPostedConsentKey = prevKey;
3093
+ return result;
3094
+ };
3095
+ const init = async (config) => {
3096
+ var _sdkConfig$consentSou;
3097
+ if (typeof window === "undefined") throw new Error("Leapio - script needs access to window object");
3098
+ disableTracking = hasExplicitDNT() || !!config.enableBrowserDNT && hasBrowserDNT() || !config.trackAutomatedBrowsers && isAutomatedBrowser();
3099
+ if (disableTracking) return;
3100
+ sdkConfig = _objectSpread2(_objectSpread2({}, DEFAULT_CONFIG), config);
3101
+ pendingEvents = [];
3102
+ superProperties = new SuperProperties(sdkConfig);
3103
+ campaign = new Campaign(sdkConfig);
3104
+ const { screen: { width, height }, navigator: { language }, location: { hostname, pathname, search, hash }, document } = window;
3105
+ const screen = `${width}x${height}`;
3106
+ currentUrl = `${pathname}${search}${hash}`;
3107
+ currentRef = document.referrer;
3108
+ anonymousId = ensureAnonymousId(getStorageOpts());
3109
+ consentStore = new ConsentStore(getStorageOpts());
3110
+ lastPostedConsentKey = consentPostKey(consentStore.get());
3111
+ detectedSignals = detectPrivacySignals();
3112
+ mergeDetectedSignalsIntoStore();
3113
+ teardownGpp === null || teardownGpp === void 0 || teardownGpp();
3114
+ teardownGpp = subscribeGpp(({ detected, gppString }) => {
3115
+ if (!detected) return;
3116
+ detectedSignals = _objectSpread2(_objectSpread2({}, detectedSignals), {}, { gppDetected: true }, gppString ? { gppString } : {});
3117
+ mergeDetectedSignalsIntoStore();
3118
+ });
3119
+ consentSourceRegistry === null || consentSourceRegistry === void 0 || consentSourceRegistry.stop();
3120
+ consentSourceRegistry = null;
3121
+ const sourceNames = (_sdkConfig$consentSou = sdkConfig.consentSources) !== null && _sdkConfig$consentSou !== void 0 ? _sdkConfig$consentSou : [];
3122
+ if (sourceNames.length > 0) {
3123
+ consentSourceRegistry = new ConsentSourceRegistry(instantiateConsentSources(sourceNames), applyConsentSnapshot);
3124
+ consentSourceRegistry.start();
3125
+ }
3126
+ getPageViewPayload = () => {
3127
+ return {
3128
+ hostname,
3129
+ screen,
3130
+ language
3131
+ };
3132
+ };
3133
+ if (sdkConfig.onLoad) await sdkConfig.onLoad();
3134
+ const normalizeUrl = (href) => {
3135
+ if (href.substring(0, 4) === "http") return "/" + href.split("/").splice(3).join("/");
3136
+ return href;
3137
+ };
3138
+ const handleRouteChange = () => {
3139
+ const nextUrl = normalizeUrl(document.location.href);
3140
+ if (nextUrl === currentUrl) return;
3141
+ engagementTracker === null || engagementTracker === void 0 || engagementTracker.pageChanged();
3142
+ currentRef = currentUrl;
3143
+ currentUrl = nextUrl;
3144
+ if (sdkConfig.enableAutoPageviews) pageview();
3145
+ };
3146
+ /**
3147
+ * SPA route-change detection. Default (History-API): patch `pushState` /
3148
+ * `replaceState` to emit a route-change signal, and listen to `popstate`
3149
+ * (back/forward) + `hashchange` (hash routers). The History-API patches are
3150
+ * restored on teardown. When `legacySpaDetection` is set, fall back to the
3151
+ * old MutationObserver-on-body heuristic instead. Returns a teardown.
3152
+ */
3153
+ const setupSpaPageviews = () => {
3154
+ if (sdkConfig.legacySpaDetection) {
3155
+ const bodyList = document.querySelector("body");
3156
+ if (!bodyList) return () => {};
3157
+ const observer = new MutationObserver(() => {
3158
+ handleRouteChange();
3159
+ });
3160
+ observer.observe(bodyList, {
3161
+ childList: true,
3162
+ subtree: true
3163
+ });
3164
+ return () => observer.disconnect();
3165
+ }
3166
+ const originalPushState = history.pushState;
3167
+ const originalReplaceState = history.replaceState;
3168
+ const onPopState = () => handleRouteChange();
3169
+ const onHashChange = () => handleRouteChange();
3170
+ const patchedPushState = function(...args) {
3171
+ const result = originalPushState.apply(this, args);
3172
+ handleRouteChange();
3173
+ return result;
3174
+ };
3175
+ const patchedReplaceState = function(...args) {
3176
+ const result = originalReplaceState.apply(this, args);
3177
+ handleRouteChange();
3178
+ return result;
3179
+ };
3180
+ history.pushState = patchedPushState;
3181
+ history.replaceState = patchedReplaceState;
3182
+ window.addEventListener("popstate", onPopState);
3183
+ window.addEventListener("hashchange", onHashChange);
3184
+ return () => {
3185
+ if (history.pushState === patchedPushState) history.pushState = originalPushState;
3186
+ if (history.replaceState === patchedReplaceState) history.replaceState = originalReplaceState;
3187
+ window.removeEventListener("popstate", onPopState);
3188
+ window.removeEventListener("hashchange", onHashChange);
3189
+ };
3190
+ };
3191
+ const update = () => {
3192
+ if (document.readyState === "complete") {
3193
+ if (sdkConfig.enableAutoPageviews) {
3194
+ pageview();
3195
+ if (!teardownSpaPageviews) teardownSpaPageviews = setupSpaPageviews();
3196
+ }
3197
+ }
3198
+ };
3199
+ teardownReadyListener === null || teardownReadyListener === void 0 || teardownReadyListener();
3200
+ document.addEventListener("readystatechange", update, true);
3201
+ teardownReadyListener = () => document.removeEventListener("readystatechange", update, true);
3202
+ update();
3203
+ teardownClassClicks === null || teardownClassClicks === void 0 || teardownClassClicks();
3204
+ teardownClassClicks = listenToClassClicks(({ name }) => {
3205
+ var _sdkConfig$pageviewsC;
3206
+ return track({
3207
+ event: name,
3208
+ channel: (_sdkConfig$pageviewsC = sdkConfig.pageviewsChannel) !== null && _sdkConfig$pageviewsC !== void 0 ? _sdkConfig$pageviewsC : null
3209
+ });
3210
+ });
3211
+ teardownPaymentLinks === null || teardownPaymentLinks === void 0 || teardownPaymentLinks();
3212
+ teardownPaymentLinks = sdkConfig.tagPaymentLinks ? listenForPaymentLinks({
3213
+ getAnonymousId: () => anonymousId,
3214
+ shouldTag: () => !disableTracking && canSendNow()
3215
+ }) : null;
3216
+ applyAutocapture();
3217
+ applyEngagement();
3218
+ };
3219
+ /**
3220
+ * Resolve the engagement mode (snippet ?? dashboard ?? "off") and (re)start
3221
+ * the tracker when it changed. Called on init and after each track response,
3222
+ * like autocapture, so a dashboard change applies without a reload.
3223
+ */
3224
+ const applyEngagement = () => {
3225
+ var _readRemoteConfig, _sdkConfig$engagement;
3226
+ if (!sdkConfig || disableTracking) return;
3227
+ const remote = (_readRemoteConfig = readRemoteConfig(sdkConfig.publicKey)) === null || _readRemoteConfig === void 0 ? void 0 : _readRemoteConfig.engagement;
3228
+ const mode = (_sdkConfig$engagement = sdkConfig.engagement) !== null && _sdkConfig$engagement !== void 0 ? _sdkConfig$engagement : isEngagementMode(remote) ? remote : "off";
3229
+ if (mode === appliedEngagementMode) return;
3230
+ appliedEngagementMode = mode;
3231
+ engagementTracker === null || engagementTracker === void 0 || engagementTracker.teardown();
3232
+ engagementTracker = mode === "off" ? null : setupEngagement({
3233
+ mode,
3234
+ send: (event, metadata) => {
3235
+ var _sdkConfig$pageviewsC2;
3236
+ track({
3237
+ event,
3238
+ channel: (_sdkConfig$pageviewsC2 = sdkConfig.pageviewsChannel) !== null && _sdkConfig$pageviewsC2 !== void 0 ? _sdkConfig$pageviewsC2 : null,
3239
+ metadata
3240
+ });
3241
+ }
3242
+ });
3243
+ };
3244
+ /**
3245
+ * Compute the effective autocapture config (snippet ?? server ?? default) and
3246
+ * (re)attach the delegated listeners. Idempotent: tears down any existing
3247
+ * listeners first, so it is safe to call again when a fresher server config
3248
+ * arrives on a track response. When autocapture resolves OFF, this leaves no
3249
+ * listeners attached (fail-safe), and core pageview tracking is unaffected.
3250
+ */
3251
+ const applyAutocapture = () => {
3252
+ if (!sdkConfig || disableTracking) return;
3253
+ const remote = readRemoteConfig(sdkConfig.publicKey);
3254
+ const autocaptureConfig = resolveEffectiveAutocaptureConfig(sdkConfig.autocapture, sdkConfig.enableAutoClicks, remote);
3255
+ const nextKey = JSON.stringify(autocaptureConfig);
3256
+ if (nextKey === appliedAutocaptureKey) return;
3257
+ appliedAutocaptureKey = nextKey;
3258
+ teardownAutocapture === null || teardownAutocapture === void 0 || teardownAutocapture();
3259
+ teardownAutocapture = null;
3260
+ if (autocaptureConfig) teardownAutocapture = setupAutocapture({
3261
+ config: autocaptureConfig,
3262
+ shouldTrack: () => !disableTracking,
3263
+ track: ({ event, metadata }) => {
3264
+ var _sdkConfig$pageviewsC3;
3265
+ return track({
3266
+ event,
3267
+ channel: (_sdkConfig$pageviewsC3 = sdkConfig.pageviewsChannel) !== null && _sdkConfig$pageviewsC3 !== void 0 ? _sdkConfig$pageviewsC3 : null,
3268
+ metadata
3269
+ });
3270
+ }
3271
+ });
3272
+ };
3273
+ const track = async (data) => {
3274
+ var _consentStore$get2;
3275
+ if (!sdkConfig || disableTracking) return;
3276
+ if (!sdkConfig.externalApiUrl && !sdkConfig.publicKey) {
3277
+ console.error("Leapio - SDK not configured. You need to call: leapio.init({publicKey: 'YOUR_PUBLIC_KEY'})");
3278
+ return;
3279
+ }
3280
+ if (data.value != null && data.value !== 0 && !data.currency) console.warn("Leapio - payment sent without a currency. Revenue reports leave it out unless the project counts payments without a currency. Pass currency, e.g. { currency: 'USD' }.");
3281
+ const analytics = consentStore === null || consentStore === void 0 || (_consentStore$get2 = consentStore.get()) === null || _consentStore$get2 === void 0 ? void 0 : _consentStore$get2.categories.analytics;
3282
+ if (isExpressMode()) {
3283
+ if (analytics === true) {
3284
+ await sendEvent(data);
3285
+ return;
3286
+ }
3287
+ if (analytics === false) return;
3288
+ if (pendingEvents.length >= MAX_PENDING_EVENTS) pendingEvents.shift();
3289
+ pendingEvents.push(_objectSpread2(_objectSpread2({}, data), {}, {
3290
+ url: currentUrl,
3291
+ referrer: currentRef,
3292
+ anonymousId
3293
+ }));
3294
+ return;
3295
+ }
3296
+ if (isImpliedMode() && analytics === false) return;
3297
+ await sendEvent(data);
3298
+ };
3299
+ /**
3300
+ * Compute the observed snippet-override report to attach to the next track
3301
+ * request, if any. Derives the EXPLICIT snippet override set from the same
3302
+ * normalization the autocapture resolver uses, then defers to the localStorage
3303
+ * dedup in `ac-overrides.ts`: returns a report only when the override set
3304
+ * differs from the last one reported for this public key (or, in the CLEAR
3305
+ * case, once after the overrides are removed). Best-effort — any failure
3306
+ * yields null so tracking is never affected.
3307
+ */
3308
+ const computeAcOverridesReport = () => {
3309
+ try {
3310
+ const normalized = normalizeSnippet(sdkConfig.autocapture);
3311
+ const report = buildSnippetOverrideReport(normalized, sdkConfig.enableAutoClicks);
3312
+ let url = null;
3313
+ try {
3314
+ const href = window.location.href;
3315
+ url = href ? href.split("#")[0] : null;
3316
+ if (url && sdkConfig.stripQueryParams) {
3317
+ var _stripUrl;
3318
+ url = (_stripUrl = stripUrl(url, sdkConfig.queryParamAllowlist)) !== null && _stripUrl !== void 0 ? _stripUrl : url;
3319
+ }
3320
+ } catch (_unused) {
3321
+ url = null;
3322
+ }
3323
+ return resolveAcOverridesToReport({
3324
+ publicKey: sdkConfig.publicKey,
3325
+ report,
3326
+ url
3327
+ });
3328
+ } catch (_unused2) {
3329
+ return null;
3330
+ }
3331
+ };
3332
+ /**
3333
+ * Build the wire payload for one event and send it, stamping the current
3334
+ * consent snapshot. Shared by `track()` and the express-mode flush.
3335
+ */
3336
+ const sendEvent = async (data) => {
3337
+ var _data$url, _ref2, _ref3, _data$userId, _data$anonymousId, _data$referrer, _data$metadata, _consentStore$get3;
3338
+ const payload = getPageViewPayload();
3339
+ const _superProperties$getP = superProperties.getProperties(), { userId: propsUserId } = _superProperties$getP, props = _objectWithoutProperties(_superProperties$getP, _excluded);
3340
+ const campaignParams = campaign.getCampaignParams();
3341
+ const cleanedUrl = removeQueryParamsFromUrl((_data$url = data.url) !== null && _data$url !== void 0 ? _data$url : currentUrl, CAMPAIGN_KEYWORDS);
3342
+ const resolvedUserId = (_ref2 = (_ref3 = (_data$userId = data.userId) !== null && _data$userId !== void 0 ? _data$userId : propsUserId ? `${propsUserId}` : null) !== null && _ref3 !== void 0 ? _ref3 : identifiedUserId) !== null && _ref2 !== void 0 ? _ref2 : null;
3343
+ await trackEvent(sdkConfig, {
3344
+ event: data.event,
3345
+ channel: data.channel,
3346
+ message: data.message,
3347
+ notify: data.notify,
3348
+ userId: resolvedUserId,
3349
+ anonymousId: (_data$anonymousId = data.anonymousId) !== null && _data$anonymousId !== void 0 ? _data$anonymousId : anonymousId,
3350
+ groupId: data.groupId,
3351
+ groupType: data.groupType,
3352
+ hostname: payload.hostname,
3353
+ language: payload.language,
3354
+ referrer: (_data$referrer = data.referrer) !== null && _data$referrer !== void 0 ? _data$referrer : currentRef,
3355
+ screen: payload.screen,
3356
+ url: cleanedUrl,
3357
+ value: data.value,
3358
+ currency: data.currency,
3359
+ metadata: _objectSpread2(_objectSpread2(_objectSpread2({}, campaignParams), props), (_data$metadata = data.metadata) !== null && _data$metadata !== void 0 ? _data$metadata : {})
3360
+ }, (_consentStore$get3 = consentStore === null || consentStore === void 0 ? void 0 : consentStore.get()) !== null && _consentStore$get3 !== void 0 ? _consentStore$get3 : null, computeAcOverridesReport());
3361
+ applyAutocapture();
3362
+ applyEngagement();
3363
+ };
3364
+ /**
3365
+ * Flush the express-mode queue once a snapshot is known. If analytics is
3366
+ * explicitly denied in the snapshot, drop the queue instead (the server is
3367
+ * the enforcement backstop, but we avoid sending events we know will be
3368
+ * dropped). Called after any snapshot change.
3369
+ */
3370
+ const flushPendingEvents = async () => {
3371
+ if (!pendingEvents.length) return;
3372
+ const queued = pendingEvents;
3373
+ pendingEvents = [];
3374
+ for (let i = 0; i < queued.length; i++) {
3375
+ var _consentStore$get4;
3376
+ const analytics = consentStore === null || consentStore === void 0 || (_consentStore$get4 = consentStore.get()) === null || _consentStore$get4 === void 0 ? void 0 : _consentStore$get4.categories.analytics;
3377
+ if (analytics === true) await sendEvent(queued[i]);
3378
+ else if (analytics === false) return;
3379
+ else {
3380
+ pendingEvents.push(...queued.slice(i));
3381
+ return;
3382
+ }
3383
+ }
3384
+ };
3385
+ /**
3386
+ * Public API: track a revenue event. Thin wrapper over `track()` that emits
3387
+ * the reserved revenue event name (default "Payment") with the monetary
3388
+ * `value` (MAJOR units, e.g. dollars) and ISO-4217 `currency`. Any other
3389
+ * options become event metadata (e.g. product, plan, interval, quantity).
3390
+ *
3391
+ * For Stripe attribution, pass `leapio.getAnonymousId()` (or the
3392
+ * `leapio_anonymous_id` cookie) into your Stripe Checkout session metadata as
3393
+ * `leapio_anonymous_id` so the connected-account webhook can link the payment
3394
+ * back to this visitor.
3395
+ */
3396
+ const revenue = async (amount, options = {}) => {
3397
+ if (!sdkConfig || disableTracking) return;
3398
+ if (typeof amount !== "number" || !isFinite(amount) || amount < 0) {
3399
+ console.error("Leapio - revenue() requires a finite, non-negative amount");
3400
+ return;
3401
+ }
3402
+ const { currency, event, channel, userId } = options, metadata = _objectWithoutProperties(options, _excluded2);
3403
+ await track({
3404
+ event: event !== null && event !== void 0 ? event : DEFAULT_REVENUE_EVENT_NAME,
3405
+ channel,
3406
+ userId,
3407
+ value: amount,
3408
+ currency,
3409
+ metadata
3410
+ });
3411
+ };
3412
+ const pageview = async (data) => {
3413
+ var _sdkConfig$pageviewsC4;
3414
+ if (!sdkConfig || disableTracking) return;
3415
+ await track({
3416
+ event: "Pageview",
3417
+ channel: (_sdkConfig$pageviewsC4 = sdkConfig.pageviewsChannel) !== null && _sdkConfig$pageviewsC4 !== void 0 ? _sdkConfig$pageviewsC4 : null,
3418
+ userId: data === null || data === void 0 ? void 0 : data.userId,
3419
+ metadata: data === null || data === void 0 ? void 0 : data.metadata
3420
+ });
3421
+ };
3422
+ const register = (properties) => {
3423
+ if (!sdkConfig || disableTracking) return true;
3424
+ return superProperties.register(properties);
3425
+ };
3426
+ const getProperties = () => {
3427
+ if (!sdkConfig || disableTracking) return {};
3428
+ return superProperties.getProperties();
3429
+ };
3430
+ const unregister = (property) => {
3431
+ if (!sdkConfig || disableTracking) return;
3432
+ superProperties.unregister(property);
3433
+ };
3434
+ const identify = async (newUserId, traits, opts) => {
3435
+ var _consentStore$get5;
3436
+ if (!sdkConfig) {
3437
+ console.warn("Leapio - identify() called before init()");
3438
+ return {
3439
+ ok: false,
3440
+ reason: "not_initialized"
3441
+ };
3442
+ }
3443
+ if (disableTracking) return {
3444
+ ok: false,
3445
+ reason: "tracking_disabled"
3446
+ };
3447
+ const normalizedUserId = `${newUserId !== null && newUserId !== void 0 ? newUserId : ""}`.trim();
3448
+ if (!normalizedUserId) {
3449
+ console.error("Leapio - identify requires a userId");
3450
+ return {
3451
+ ok: false,
3452
+ reason: "missing_user_id"
3453
+ };
3454
+ }
3455
+ const firstTouch = sanitizeFirstTouch(campaign.getFirstTouchParams());
3456
+ const mergedTraits = Object.keys(firstTouch).length > 0 ? _objectSpread2({ first_touch: firstTouch }, traits) : traits;
3457
+ const result = await identifyUser(sdkConfig, {
3458
+ anonymousId,
3459
+ userId: normalizedUserId,
3460
+ traits: mergedTraits,
3461
+ options: opts
3462
+ }, (_consentStore$get5 = consentStore === null || consentStore === void 0 ? void 0 : consentStore.get()) !== null && _consentStore$get5 !== void 0 ? _consentStore$get5 : null);
3463
+ if (result.ok) {
3464
+ var _opts$identityVerific;
3465
+ identifiedUserId = normalizedUserId;
3466
+ lastIdentityVerification = (_opts$identityVerific = opts === null || opts === void 0 ? void 0 : opts.identityVerification) !== null && _opts$identityVerific !== void 0 ? _opts$identityVerific : null;
3467
+ superProperties.register({ userId: identifiedUserId });
3468
+ }
3469
+ return result;
3470
+ };
3471
+ /**
3472
+ * Associate the current visitor with a group/account (Segment `group()`), e.g.
3473
+ * a company. Binds to the identified `user_id` when known, otherwise to the
3474
+ * managed `anonymousId` (the platform promotes anon memberships to the identity
3475
+ * on the next identify). Not gated on consent: a customer-directed linkage call
3476
+ * (legitimate interest); the server gates egress by purpose.
3477
+ */
3478
+ const group = async (groupId, traits, opts) => {
3479
+ if (!sdkConfig) {
3480
+ console.warn("Leapio - group() called before init()");
3481
+ return {
3482
+ ok: false,
3483
+ reason: "not_initialized"
3484
+ };
3485
+ }
3486
+ if (disableTracking) return {
3487
+ ok: false,
3488
+ reason: "tracking_disabled"
3489
+ };
3490
+ const normalizedGroupId = `${groupId !== null && groupId !== void 0 ? groupId : ""}`.trim();
3491
+ if (!normalizedGroupId) {
3492
+ console.error("Leapio - group requires a groupId");
3493
+ return {
3494
+ ok: false,
3495
+ reason: "missing_group_id"
3496
+ };
3497
+ }
3498
+ return groupUser(sdkConfig, {
3499
+ anonymousId,
3500
+ userId: identifiedUserId,
3501
+ groupId: normalizedGroupId,
3502
+ type: opts === null || opts === void 0 ? void 0 : opts.type,
3503
+ traits,
3504
+ options: opts
3505
+ });
3506
+ };
3507
+ /** Apply the url-privacy toggle to first-touch urls before they egress. */
3508
+ const sanitizeFirstTouch = (firstTouch) => {
3509
+ if (!(sdkConfig === null || sdkConfig === void 0 ? void 0 : sdkConfig.stripQueryParams)) return firstTouch;
3510
+ const sanitized = _objectSpread2({}, firstTouch);
3511
+ for (const key of ["landing_url", "referrer"]) if (sanitized[key]) {
3512
+ var _stripUrl2;
3513
+ sanitized[key] = (_stripUrl2 = stripUrl(sanitized[key], sdkConfig.queryParamAllowlist)) !== null && _stripUrl2 !== void 0 ? _stripUrl2 : sanitized[key];
3514
+ }
3515
+ return sanitized;
3516
+ };
3517
+ /**
3518
+ * Internal: apply a snapshot from a consent source through the store's
3519
+ * priority-aware `applySnapshot`. If accepted (became/stayed authoritative),
3520
+ * persists, flushes any express-mode queue, and pushes to `/consent`.
3521
+ *
3522
+ * This is the internal seam the consent-source plugins write through: a
3523
+ * source (Concord/OneTrust/GCM reader/...) constructs a
3524
+ * `ConsentSnapshot` with its `source` id and calls this; the store keeps the
3525
+ * highest-priority snapshot authoritative.
3526
+ *
3527
+ * Returns `true` if the snapshot was accepted, `false` if a higher-priority
3528
+ * source already owns the state.
3529
+ */
3530
+ const applyConsentSnapshot = async (snapshot) => {
3531
+ if (!sdkConfig || disableTracking || !consentStore) return false;
3532
+ if (!consentStore.applySnapshot(snapshot)) return false;
3533
+ mergeDetectedSignalsIntoStore();
3534
+ await flushPendingEvents();
3535
+ await pushConsentIfChanged();
3536
+ return true;
3537
+ };
3538
+ /**
3539
+ * Public API: explicit consent (highest-priority source `leapio_api`).
3540
+ * Merge-partial update — unspecified categories keep their prior value.
3541
+ * Persists, stamps subsequent events, flushes the express queue, and POSTs
3542
+ * the new state to `/consent`.
3543
+ */
3544
+ const setConsent = async (input, opts) => {
3545
+ if (!sdkConfig) {
3546
+ console.warn("Leapio - setConsent() called before init()");
3547
+ return {
3548
+ ok: false,
3549
+ reason: "not_initialized"
3550
+ };
3551
+ }
3552
+ if (disableTracking) return {
3553
+ ok: false,
3554
+ reason: "tracking_disabled"
3555
+ };
3556
+ const categories = {};
3557
+ if (typeof input.analytics === "boolean") categories.analytics = input.analytics;
3558
+ if (typeof input.functional === "boolean") categories.functional = input.functional;
3559
+ if (typeof input.marketing === "boolean") categories.marketing = input.marketing;
3560
+ const signals = typeof input.doNotSellShare === "boolean" ? { doNotSellShare: input.doNotSellShare } : {};
3561
+ consentStore.mergeSnapshot({
3562
+ categories,
3563
+ signals
3564
+ }, "leapio_api");
3565
+ mergeDetectedSignalsIntoStore();
3566
+ await flushPendingEvents();
3567
+ return pushConsentIfChanged(opts === null || opts === void 0 ? void 0 : opts.identityVerification);
3568
+ };
3569
+ /** Public API: read the current consent snapshot (or null if none yet). */
3570
+ const getConsent = () => {
3571
+ if (!sdkConfig || disableTracking || !consentStore) return null;
3572
+ return consentStore.get();
3573
+ };
3574
+ const reset = () => {
3575
+ var _sdkConfig$consentSou2;
3576
+ if (!sdkConfig || disableTracking) return;
3577
+ superProperties.clear();
3578
+ identifiedUserId = null;
3579
+ lastIdentityVerification = null;
3580
+ anonymousId = rotateAnonymousId(getStorageOpts());
3581
+ consentStore === null || consentStore === void 0 || consentStore.clear();
3582
+ pendingEvents = [];
3583
+ lastPostedConsentKey = null;
3584
+ consentSourceRegistry === null || consentSourceRegistry === void 0 || consentSourceRegistry.stop();
3585
+ consentSourceRegistry = null;
3586
+ const sourceNames = (_sdkConfig$consentSou2 = sdkConfig.consentSources) !== null && _sdkConfig$consentSou2 !== void 0 ? _sdkConfig$consentSou2 : [];
3587
+ if (sourceNames.length > 0) {
3588
+ consentSourceRegistry = new ConsentSourceRegistry(instantiateConsentSources(sourceNames), applyConsentSnapshot);
3589
+ consentSourceRegistry.start();
3590
+ }
3591
+ };
3592
+ /**
3593
+ * The current anonymous visitor id. This is the value to pass into your
3594
+ * Stripe Checkout session metadata as `leapio_anonymous_id` (it mirrors the
3595
+ * `leapio_anonymous_id` cookie) so Leapio's connected-account webhook can
3596
+ * attribute the resulting payment back to this visitor and their account.
3597
+ * Returns null when tracking is disabled or the SDK is not initialized.
3598
+ */
3599
+ const getAnonymousId = () => {
3600
+ if (!sdkConfig || disableTracking) return null;
3601
+ return anonymousId;
3602
+ };
3603
+ return {
3604
+ init,
3605
+ track,
3606
+ revenue,
3607
+ pageview,
3608
+ register,
3609
+ unregister,
3610
+ reset,
3611
+ identify,
3612
+ group,
3613
+ setConsent,
3614
+ getConsent,
3615
+ applyConsentSnapshot,
3616
+ getProperties,
3617
+ getAnonymousId
3618
+ };
3619
+ };
3620
+ const leapio = Leapio();
3621
+ //#endregion
3622
+ export { DEFAULT_QUERY_PARAM_ALLOWLIST, leapio as default, leapio };