@holdyapp/sdk 1.1.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web.mjs CHANGED
@@ -4,6 +4,7 @@ var DEFAULT_FLUSH_AT = 20;
4
4
  var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
5
5
  var MAX_QUEUE = 1e3;
6
6
  var BULK_LIMIT = 500;
7
+ var MAX_BACKOFF_MS = 6e4;
7
8
  var HoldyCore = class {
8
9
  apiKey;
9
10
  baseUrl;
@@ -13,6 +14,9 @@ var HoldyCore = class {
13
14
  queue = [];
14
15
  timer = null;
15
16
  flushing = null;
17
+ retryAt = 0;
18
+ failures = 0;
19
+ warned = /* @__PURE__ */ new Set();
16
20
  userId = null;
17
21
  anonymousId = null;
18
22
  accountKey = null;
@@ -46,13 +50,14 @@ var HoldyCore = class {
46
50
  event: name,
47
51
  occurred_at: occurredAt,
48
52
  source: this.source,
53
+ external_id: newEventId(),
49
54
  ...userId ? { user_id: userId } : {},
50
55
  ...this.anonymousId ? { anonymous_id: this.anonymousId } : {},
51
56
  properties: properties ?? {},
52
57
  ...Object.keys(context).length > 0 ? { context } : {}
53
58
  };
54
59
  this.queue.push(event);
55
- if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
60
+ this.trimQueue();
56
61
  if (this.queue.length >= this.flushAt) void this.flush();
57
62
  }
58
63
  identify(userId, traits) {
@@ -65,14 +70,9 @@ var HoldyCore = class {
65
70
  this.accountKey = accountKey;
66
71
  this.track("$group", traits ?? {});
67
72
  }
68
- /** Drena a fila. Nunca lança — falha re-enfileira e loga em debug. */
73
+ /** Drena a fila. Nunca lança — falha re-enfileira e espera antes de tentar de novo. */
69
74
  async flush() {
70
- if (this.flushing) return this.flushing;
71
- if (this.queue.length === 0) return;
72
- this.flushing = this.drain().finally(() => {
73
- this.flushing = null;
74
- });
75
- return this.flushing;
75
+ return this.startFlush(false);
76
76
  }
77
77
  /** Flush final + para o timer. Chamar em shutdown do server / unload do web. */
78
78
  async shutdown() {
@@ -80,17 +80,42 @@ var HoldyCore = class {
80
80
  clearInterval(this.timer);
81
81
  this.timer = null;
82
82
  }
83
- await this.flush();
83
+ await this.startFlush(true);
84
+ }
85
+ startFlush(force) {
86
+ if (this.flushing) return this.flushing;
87
+ if (this.queue.length === 0) return Promise.resolve();
88
+ if (!force && Date.now() < this.retryAt) return Promise.resolve();
89
+ this.flushing = this.drain().finally(() => {
90
+ this.flushing = null;
91
+ });
92
+ return this.flushing;
84
93
  }
85
94
  async drain() {
86
95
  while (this.queue.length > 0) {
87
96
  const batch = this.queue.splice(0, BULK_LIMIT);
88
- const ok = await this.send(batch);
89
- if (!ok) {
97
+ const outcome = await this.send(batch);
98
+ if (outcome.kind === "retry") {
90
99
  this.queue.unshift(...batch);
100
+ this.trimQueue();
101
+ this.scheduleRetry(outcome.afterMs);
91
102
  return;
92
103
  }
104
+ this.failures = 0;
105
+ this.retryAt = 0;
106
+ }
107
+ }
108
+ scheduleRetry(afterMs) {
109
+ this.failures += 1;
110
+ if (afterMs !== null) {
111
+ this.retryAt = Date.now() + afterMs;
112
+ return;
93
113
  }
114
+ const backoff = Math.min(MAX_BACKOFF_MS, 1e3 * 2 ** (this.failures - 1));
115
+ this.retryAt = Date.now() + backoff + Math.floor(Math.random() * backoff * 0.2);
116
+ }
117
+ trimQueue() {
118
+ if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
94
119
  }
95
120
  async send(events) {
96
121
  try {
@@ -103,23 +128,38 @@ var HoldyCore = class {
103
128
  body: JSON.stringify({ events }),
104
129
  ...this.source === "sdk_web" ? { keepalive: true } : {}
105
130
  });
106
- if (!res.ok) {
107
- if (res.status >= 400 && res.status < 500) {
108
- this.log(`bulk rejected ${res.status} \xB7 batch dropped`);
109
- return true;
110
- }
111
- this.log(`bulk failed ${res.status} \xB7 will retry`);
112
- return false;
131
+ if (res.ok) return { kind: "sent" };
132
+ if (res.status === 429) {
133
+ this.log("rate limited \xB7 will retry after Retry-After");
134
+ return { kind: "retry", afterMs: retryAfterMs(res) };
113
135
  }
114
- return true;
136
+ if (res.status >= 500) {
137
+ this.log(`bulk failed ${res.status} \xB7 will retry with backoff`);
138
+ return { kind: "retry", afterMs: null };
139
+ }
140
+ if (res.status === 401) {
141
+ this.warnOnce("401", "API key inv\xE1lida ou revogada \xB7 eventos descartados. Confira a chave em Configura\xE7\xF5es \u203A SDK.");
142
+ return { kind: "dropped" };
143
+ }
144
+ if (res.status === 403) {
145
+ this.warnOnce("403", "Origem n\xE3o permitida ou chave sem escopo events:write \xB7 eventos descartados.");
146
+ return { kind: "dropped" };
147
+ }
148
+ this.log(`bulk rejected ${res.status} \xB7 batch dropped`);
149
+ return { kind: "dropped" };
115
150
  } catch (err) {
116
- this.log(`bulk network error: ${err.message} \xB7 will retry`);
117
- return false;
151
+ this.log(`bulk network error: ${err.message} \xB7 will retry with backoff`);
152
+ return { kind: "retry", afterMs: null };
118
153
  }
119
154
  }
120
155
  log(msg) {
121
156
  if (this.debug) console.warn(`[holdy] ${msg}`);
122
157
  }
158
+ warnOnce(key, msg) {
159
+ if (this.warned.has(key)) return;
160
+ this.warned.add(key);
161
+ console.warn(`[holdy] ${msg}`);
162
+ }
123
163
  /**
124
164
  * Ambiente que o SDK observa sozinho (url, referrer, browser, utm…).
125
165
  * Vazio no core; `HoldyWeb` preenche. O server não infere ambiente — não
@@ -133,6 +173,19 @@ var HoldyCore = class {
133
173
  return this.queue.length;
134
174
  }
135
175
  };
176
+ function retryAfterMs(res) {
177
+ const raw = typeof res.headers?.get === "function" ? res.headers.get("Retry-After") : null;
178
+ if (!raw) return null;
179
+ const seconds = Number(raw);
180
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
181
+ return Math.min(MAX_BACKOFF_MS, seconds * 1e3);
182
+ }
183
+ function newEventId() {
184
+ const c = globalThis.crypto;
185
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
186
+ const rand = () => Math.random().toString(36).slice(2, 12);
187
+ return `${Date.now().toString(36)}-${rand()}-${rand()}`;
188
+ }
136
189
 
137
190
  // src/auto-capture.ts
138
191
  var INSTALLED_FLAG = "__holdy_autocapture__";
@@ -197,6 +250,302 @@ function currentPath() {
197
250
  return `${location.pathname}${location.search}${location.hash}`;
198
251
  }
199
252
 
253
+ // src/friction.ts
254
+ var INSTALLED_FLAG2 = "__holdy_friction__";
255
+ var INTERACTIVE_SELECTOR = 'button, a, input, select, textarea, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"]';
256
+ var INTERACTIVE_TAGS = /* @__PURE__ */ new Set(["button", "a", "input", "select", "textarea", "summary"]);
257
+ var FORM_CONTROL_TAGS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
258
+ var RageClickDetector = class {
259
+ constructor(threshold = 3, windowMs = 1e3, radiusPx = 30) {
260
+ this.threshold = threshold;
261
+ this.windowMs = windowMs;
262
+ this.radiusPx = radiusPx;
263
+ }
264
+ clicks = [];
265
+ push(x, y, t) {
266
+ this.clicks = this.clicks.filter(
267
+ (c) => t - c.t <= this.windowMs && Math.hypot(c.x - x, c.y - y) <= this.radiusPx
268
+ );
269
+ this.clicks.push({ x, y, t });
270
+ if (this.clicks.length >= this.threshold) {
271
+ this.clicks = [];
272
+ return true;
273
+ }
274
+ return false;
275
+ }
276
+ };
277
+ var NavLoopDetector = class {
278
+ constructor(windowMs = 6e4) {
279
+ this.windowMs = windowMs;
280
+ }
281
+ visits = [];
282
+ push(path, t) {
283
+ this.visits = [...this.visits, { path, t }].filter((v) => t - v.t <= this.windowMs).slice(-4);
284
+ if (this.visits.length < 4) return null;
285
+ const [a, b, c, d] = this.visits.map((v) => v.path);
286
+ if (a === c && b === d && a !== b) {
287
+ this.visits = [];
288
+ return { from: a, to: b };
289
+ }
290
+ return null;
291
+ }
292
+ };
293
+ function resolveInteractive(el) {
294
+ if (!el) return null;
295
+ const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null;
296
+ if (viaClosest) return viaClosest;
297
+ const tag = el.tagName.toLowerCase();
298
+ if (INTERACTIVE_TAGS.has(tag)) return el;
299
+ const role = el.getAttribute("role");
300
+ if (role === "button" || role === "link" || role === "menuitem" || role === "tab") return el;
301
+ return null;
302
+ }
303
+ function isDeadClickTarget(el) {
304
+ const target = resolveInteractive(el);
305
+ if (!target) return false;
306
+ if (target.disabled === true) return true;
307
+ if (target.getAttribute("disabled") !== null) return true;
308
+ if (target.getAttribute("aria-disabled") === "true") return true;
309
+ const tag = target.tagName.toLowerCase();
310
+ if (tag === "a") {
311
+ const href = target.getAttribute("href");
312
+ if (!href || href === "#" || href.startsWith("javascript:")) return true;
313
+ }
314
+ return false;
315
+ }
316
+ function describeElement(el) {
317
+ const target = resolveInteractive(el) ?? el;
318
+ if (!target) return {};
319
+ const out = { element: target.tagName.toLowerCase() };
320
+ if (target.id) out.element_id = target.id;
321
+ const role = target.getAttribute("role");
322
+ if (role) out.element_role = role;
323
+ const label = target.getAttribute("aria-label") ?? target.textContent ?? "";
324
+ const text = label.replace(/\s+/g, " ").trim().slice(0, 60);
325
+ if (text) out.element_text = text;
326
+ const name = target.getAttribute("name") ?? target.getAttribute("data-testid");
327
+ if (name) out.element_name = name;
328
+ return out;
329
+ }
330
+ function stripRequestUrl(input, base) {
331
+ try {
332
+ const url = new URL(input, base);
333
+ return `${url.origin}${url.pathname}`;
334
+ } catch {
335
+ return input.split("?")[0].split("#")[0];
336
+ }
337
+ }
338
+ function errorMessage(reason) {
339
+ if (reason instanceof Error) return reason.message;
340
+ if (typeof reason === "string") return reason;
341
+ try {
342
+ return JSON.stringify(reason);
343
+ } catch {
344
+ return String(reason);
345
+ }
346
+ }
347
+ function isAbortError(err) {
348
+ return typeof err === "object" && err !== null && err.name === "AbortError";
349
+ }
350
+ function isIgnoredUrl(url, ignore) {
351
+ return ignore.some((prefix) => prefix.length > 0 && url.startsWith(prefix));
352
+ }
353
+ function registrableDomain(host) {
354
+ const labels = host.toLowerCase().split(".").filter(Boolean);
355
+ if (labels.length <= 2) return labels.join(".");
356
+ return labels.slice(-2).join(".");
357
+ }
358
+ function isFirstPartyRequest(url, pageUrl, extraOrigins = []) {
359
+ let target;
360
+ let page;
361
+ try {
362
+ target = new URL(url, pageUrl);
363
+ page = new URL(pageUrl);
364
+ } catch {
365
+ return true;
366
+ }
367
+ if (target.protocol !== "http:" && target.protocol !== "https:") return true;
368
+ if (target.host === page.host) return true;
369
+ if (extraOrigins.some((o) => o.length > 0 && target.origin === o)) return true;
370
+ const targetDomain = registrableDomain(target.hostname);
371
+ return targetDomain.length > 0 && targetDomain === registrableDomain(page.hostname);
372
+ }
373
+ function classifyRequest(outcome) {
374
+ if (outcome.aborted) return null;
375
+ if (!outcome.opaque && (outcome.status === null || outcome.status >= 400)) return "http_error";
376
+ return outcome.durationMs >= outcome.slowMs ? "slow_request" : null;
377
+ }
378
+ function documentStatus(perf) {
379
+ try {
380
+ const entry = perf?.getEntriesByType?.("navigation")?.[0];
381
+ const status = entry?.responseStatus;
382
+ return typeof status === "number" && status > 0 ? status : null;
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+ function formKey(form) {
388
+ return form.id || form.getAttribute("name") || form.getAttribute("data-testid") || stripRequestUrl(form.getAttribute("action") ?? "", location.href).replace(location.origin, "") || "form";
389
+ }
390
+ function currentPath2() {
391
+ return typeof location === "undefined" ? "/" : location.pathname || "/";
392
+ }
393
+ function installFrictionCapture(opts) {
394
+ const noop = { onNavigate: () => void 0, teardown: () => void 0 };
395
+ if (typeof window === "undefined" || typeof document === "undefined") return noop;
396
+ const w = window;
397
+ if (w[INSTALLED_FLAG2]) return noop;
398
+ w[INSTALLED_FLAG2] = true;
399
+ const emit = (kind, properties) => {
400
+ try {
401
+ opts.onFriction({ kind, properties: { path: currentPath2(), ...properties } });
402
+ } catch {
403
+ return;
404
+ }
405
+ };
406
+ const cleanups = [];
407
+ const rage = new RageClickDetector(opts.rageClicks, opts.rageWindowMs, opts.rageRadiusPx);
408
+ const navLoop = new NavLoopDetector(opts.navLoopWindowMs);
409
+ const slowMs = opts.slowRequestMs ?? 3e3;
410
+ const touched = /* @__PURE__ */ new Map();
411
+ const onClick = (ev) => {
412
+ const el = ev.target instanceof Element ? ev.target : null;
413
+ if (isDeadClickTarget(el)) emit("dead_click", describeElement(el));
414
+ if (rage.push(ev.clientX, ev.clientY, ev.timeStamp || Date.now())) {
415
+ emit("rage_click", describeElement(el));
416
+ }
417
+ };
418
+ document.addEventListener("click", onClick, true);
419
+ cleanups.push(() => document.removeEventListener("click", onClick, true));
420
+ const onError = (ev) => {
421
+ emit("js_error", {
422
+ message: String(ev.message ?? "").slice(0, 200),
423
+ ...ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {},
424
+ ...ev.lineno ? { line: ev.lineno } : {}
425
+ });
426
+ };
427
+ window.addEventListener("error", onError);
428
+ cleanups.push(() => window.removeEventListener("error", onError));
429
+ const onRejection = (ev) => {
430
+ emit("js_error", { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true });
431
+ };
432
+ window.addEventListener("unhandledrejection", onRejection);
433
+ cleanups.push(() => window.removeEventListener("unhandledrejection", onRejection));
434
+ const ignore = (opts.ignoreUrls ?? []).map((url) => stripRequestUrl(url, location.href));
435
+ const apiOrigins = (opts.apiOrigins ?? []).map((o) => {
436
+ try {
437
+ return new URL(o, location.href).origin;
438
+ } catch {
439
+ return "";
440
+ }
441
+ });
442
+ const reportRequest = (url, method, started, status, flags = {}) => {
443
+ const target = stripRequestUrl(url, location.href);
444
+ if (isIgnoredUrl(target, ignore)) return;
445
+ if (!isFirstPartyRequest(target, location.href, apiOrigins)) return;
446
+ const durationMs = performance.now() - started;
447
+ const kind = classifyRequest({ status, durationMs, slowMs, ...flags });
448
+ if (!kind) return;
449
+ emit(kind, {
450
+ url: target,
451
+ method,
452
+ duration_ms: Math.round(durationMs),
453
+ ...flags.opaque ? {} : status !== null ? { status } : { failed: true }
454
+ });
455
+ };
456
+ const originalFetch = window.fetch;
457
+ if (typeof originalFetch === "function") {
458
+ const patched = async (input, init) => {
459
+ const started = performance.now();
460
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
461
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
462
+ try {
463
+ const res = await originalFetch(input, init);
464
+ reportRequest(url, method, started, res.status, {
465
+ opaque: res.type === "opaque" || res.type === "opaqueredirect"
466
+ });
467
+ return res;
468
+ } catch (err) {
469
+ reportRequest(url, method, started, null, { aborted: isAbortError(err) });
470
+ throw err;
471
+ }
472
+ };
473
+ window.fetch = patched;
474
+ cleanups.push(() => {
475
+ window.fetch = originalFetch;
476
+ });
477
+ }
478
+ const xhrProto = typeof XMLHttpRequest === "function" ? XMLHttpRequest.prototype : null;
479
+ if (xhrProto) {
480
+ const originalOpen = xhrProto.open;
481
+ const originalSend = xhrProto.send;
482
+ const requests = /* @__PURE__ */ new WeakMap();
483
+ xhrProto.open = function(method, url, ...rest) {
484
+ requests.set(this, { method: String(method).toUpperCase(), url: String(url) });
485
+ return originalOpen.apply(this, [method, url, ...rest]);
486
+ };
487
+ xhrProto.send = function(body) {
488
+ const request = requests.get(this);
489
+ if (request) {
490
+ const started = performance.now();
491
+ let aborted = false;
492
+ this.addEventListener("abort", () => {
493
+ aborted = true;
494
+ });
495
+ this.addEventListener("loadend", () => {
496
+ reportRequest(request.url, request.method, started, this.status === 0 ? null : this.status, { aborted });
497
+ });
498
+ }
499
+ return originalSend.call(this, body);
500
+ };
501
+ cleanups.push(() => {
502
+ xhrProto.open = originalOpen;
503
+ xhrProto.send = originalSend;
504
+ });
505
+ }
506
+ const loadStatus = documentStatus(typeof performance === "undefined" ? void 0 : performance);
507
+ if (loadStatus !== null && loadStatus >= 400) {
508
+ emit("http_error", { document: true, status: loadStatus, url: stripRequestUrl(location.href) });
509
+ }
510
+ const onFocusIn = (ev) => {
511
+ const el = ev.target;
512
+ if (!el || !FORM_CONTROL_TAGS.has(el.tagName.toLowerCase())) return;
513
+ const form = el.form;
514
+ if (!form) return;
515
+ const entry = touched.get(form) ?? { form: formKey(form), fields: /* @__PURE__ */ new Set() };
516
+ const field = el.name || el.id || el.tagName.toLowerCase();
517
+ entry.fields.add(field);
518
+ touched.set(form, entry);
519
+ };
520
+ document.addEventListener("focusin", onFocusIn, true);
521
+ cleanups.push(() => document.removeEventListener("focusin", onFocusIn, true));
522
+ const onSubmit = (ev) => {
523
+ if (ev.target instanceof HTMLFormElement) touched.delete(ev.target);
524
+ };
525
+ document.addEventListener("submit", onSubmit, true);
526
+ cleanups.push(() => document.removeEventListener("submit", onSubmit, true));
527
+ const flushAbandons = () => {
528
+ for (const entry of touched.values()) {
529
+ emit("form_abandon", { form: entry.form, fields_touched: entry.fields.size });
530
+ }
531
+ touched.clear();
532
+ };
533
+ window.addEventListener("pagehide", flushAbandons);
534
+ cleanups.push(() => window.removeEventListener("pagehide", flushAbandons));
535
+ return {
536
+ onNavigate: (path) => {
537
+ flushAbandons();
538
+ const loop = navLoop.push(path, Date.now());
539
+ if (loop) emit("nav_loop", { from: loop.from, to: loop.to });
540
+ },
541
+ teardown: () => {
542
+ for (const cleanup of cleanups) cleanup();
543
+ touched.clear();
544
+ w[INSTALLED_FLAG2] = false;
545
+ }
546
+ };
547
+ }
548
+
200
549
  // src/web-context.ts
201
550
  var UTM_KEYS = [
202
551
  "utm_source",
@@ -239,17 +588,17 @@ function parseUserAgent(ua) {
239
588
  device
240
589
  };
241
590
  }
242
- function readStorage(storage, key) {
591
+ function readStorage(storage2, key) {
243
592
  try {
244
- const s = storage === "session" ? sessionStorage : localStorage;
593
+ const s = storage2 === "session" ? sessionStorage : localStorage;
245
594
  return s.getItem(key);
246
595
  } catch {
247
596
  return null;
248
597
  }
249
598
  }
250
- function writeStorage(storage, key, value) {
599
+ function writeStorage(storage2, key, value) {
251
600
  try {
252
- const s = storage === "session" ? sessionStorage : localStorage;
601
+ const s = storage2 === "session" ? sessionStorage : localStorage;
253
602
  s.setItem(key, value);
254
603
  } catch {
255
604
  }
@@ -314,10 +663,306 @@ function buildWebContext() {
314
663
  };
315
664
  }
316
665
 
666
+ // src/arm.ts
667
+ function fnv1a(input) {
668
+ let hash = 2166136261;
669
+ for (let i = 0; i < input.length; i++) {
670
+ hash ^= input.charCodeAt(i);
671
+ hash = Math.imul(hash, 16777619) >>> 0;
672
+ }
673
+ return hash >>> 0;
674
+ }
675
+ function changeBucket(changeId, accountKey) {
676
+ return fnv1a(`${changeId}:${accountKey}`) % 100;
677
+ }
678
+ function changeArm(changeId, accountKey, holdoutPct) {
679
+ return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
680
+ }
681
+ function matchesPathTemplate(template, path) {
682
+ const t = template.split(/[?#]/)[0].split("/").filter(Boolean);
683
+ const p = path.split(/[?#]/)[0].split("/").filter(Boolean);
684
+ if (t.length !== p.length) return false;
685
+ return t.every((segment, i) => segment.startsWith(":") || segment === p[i]);
686
+ }
687
+
688
+ // src/changes.ts
689
+ var REFRESH_MS = 5 * 60 * 1e3;
690
+ var NAV_REFRESH_MS = 60 * 1e3;
691
+ var SCAN_DEBOUNCE_MS = 250;
692
+ var SEEN_KEY = "holdy_changes_seen";
693
+ var DISMISSED_KEY = "holdy_changes_dismissed";
694
+ var TIP_GAP_PX = 8;
695
+ var TIP_CSS = `:host{all:initial}
696
+ .tip{position:fixed;box-sizing:border-box;max-width:280px;padding:10px 12px;border-radius:8px;background:#111;color:#fff;font:13px/1.45 system-ui,-apple-system,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.18);pointer-events:auto;display:flex;gap:10px;align-items:flex-start}
697
+ .tip p{margin:0;flex:1}
698
+ .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
699
+ .tip button:hover,.tip button:focus-visible{opacity:1}
700
+ @media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`;
701
+ function normalizeText(text) {
702
+ return text.replace(/\s+/g, " ").trim();
703
+ }
704
+ function parseChanges(body) {
705
+ const list = body?.changes;
706
+ return Array.isArray(list) ? list.filter(isChange) : [];
707
+ }
708
+ function isAnchor(value) {
709
+ const a = value;
710
+ if (a?.kind === "page") return true;
711
+ return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
712
+ }
713
+ function isChange(value) {
714
+ const c = value;
715
+ return typeof c?.id === "string" && (c.kind === "copy" || c.kind === "guidance") && typeof c.path === "string" && isAnchor(c.anchor) && typeof c.content?.text === "string" && typeof c.holdout_pct === "number";
716
+ }
717
+ function changesForPage(changes, path, accountKey) {
718
+ if (!accountKey) return [];
719
+ return changes.filter((c) => matchesPathTemplate(c.path, path)).map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }));
720
+ }
721
+ function storage(kind) {
722
+ try {
723
+ return kind === "local" ? window.localStorage : window.sessionStorage;
724
+ } catch {
725
+ return void 0;
726
+ }
727
+ }
728
+ function readSet(store, key) {
729
+ try {
730
+ return new Set(JSON.parse(store?.getItem(key) ?? "[]"));
731
+ } catch {
732
+ return /* @__PURE__ */ new Set();
733
+ }
734
+ }
735
+ function addToSet(store, key, value) {
736
+ try {
737
+ const set = readSet(store, key);
738
+ set.add(value);
739
+ store?.setItem(key, JSON.stringify([...set].slice(-200)));
740
+ } catch {
741
+ return;
742
+ }
743
+ }
744
+ function query(selector) {
745
+ try {
746
+ return document.querySelector(selector);
747
+ } catch {
748
+ return null;
749
+ }
750
+ }
751
+ var TEXT_ANCHOR_TAGS = 'button,a,[role="button"],label,summary,h1,h2,h3,h4,li,td,th,p,span,strong';
752
+ var TEXT_ANCHOR_SCAN_LIMIT = 3e3;
753
+ function findByText(value) {
754
+ const wanted = normalizeText(value);
755
+ let best = null;
756
+ let bestLength = Number.POSITIVE_INFINITY;
757
+ const nodes = document.querySelectorAll(TEXT_ANCHOR_TAGS);
758
+ const total = Math.min(nodes.length, TEXT_ANCHOR_SCAN_LIMIT);
759
+ for (let i = 0; i < total; i += 1) {
760
+ const el = nodes[i];
761
+ const raw = el.textContent ?? "";
762
+ if (raw.length >= bestLength || normalizeText(raw) !== wanted) continue;
763
+ best = el;
764
+ bestLength = raw.length;
765
+ }
766
+ return best;
767
+ }
768
+ function findAnchor(anchor) {
769
+ if (anchor.kind === "page") return null;
770
+ return anchor.kind === "selector" ? query(anchor.value) : findByText(anchor.value);
771
+ }
772
+ function findTextNode(el, match) {
773
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
774
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
775
+ if (match(normalizeText(node.textContent ?? ""))) return node;
776
+ }
777
+ return null;
778
+ }
779
+ function installProductChanges(opts) {
780
+ if (typeof window === "undefined" || typeof document === "undefined") {
781
+ return { onNavigate: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
782
+ }
783
+ let changes = [];
784
+ let lastFetch = 0;
785
+ let timer = null;
786
+ let root = null;
787
+ let host = null;
788
+ const copies = /* @__PURE__ */ new Map();
789
+ const tips = /* @__PURE__ */ new Map();
790
+ const log = (message) => {
791
+ if (opts.debug) console.warn(`[holdy] ${message}`);
792
+ };
793
+ const expose = (change, arm) => {
794
+ const key = `${change.id}:${arm}`;
795
+ const session = storage("session");
796
+ if (readSet(session, SEEN_KEY).has(key)) return;
797
+ addToSet(session, SEEN_KEY, key);
798
+ opts.track("$change_exposure", { change_id: change.id, arm, kind: change.kind });
799
+ };
800
+ const eligibleCopyNode = (el, change) => {
801
+ const expected = change.expected_text ? normalizeText(change.expected_text) : null;
802
+ const next = normalizeText(change.content.text);
803
+ return findTextNode(el, (text) => text.length > 0 && (text === next || expected === null || text === expected));
804
+ };
805
+ const applyCopy = (el, change) => {
806
+ const next = normalizeText(change.content.text);
807
+ const current = copies.get(change.id);
808
+ if (current && current.node.isConnected && normalizeText(current.node.textContent ?? "") === next) return true;
809
+ const node = eligibleCopyNode(el, change);
810
+ if (!node) return false;
811
+ const text = node.textContent ?? "";
812
+ if (normalizeText(text) !== next) {
813
+ copies.set(change.id, { node, original: text, next: change.content.text });
814
+ node.textContent = text.replace(text.trim(), change.content.text);
815
+ }
816
+ return true;
817
+ };
818
+ const restoreCopy = (id) => {
819
+ const applied = copies.get(id);
820
+ if (!applied) return;
821
+ if (applied.node.isConnected && normalizeText(applied.node.textContent ?? "") === normalizeText(applied.next)) {
822
+ applied.node.textContent = applied.original;
823
+ }
824
+ copies.delete(id);
825
+ };
826
+ const ensureRoot = () => {
827
+ if (root) return root;
828
+ if (!document.body) return null;
829
+ host = document.createElement("holdy-changes");
830
+ host.setAttribute("data-holdy-root", "1");
831
+ host.style.cssText = "position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none";
832
+ root = host.attachShadow({ mode: "closed" });
833
+ const style = document.createElement("style");
834
+ style.textContent = TIP_CSS;
835
+ root.appendChild(style);
836
+ document.body.appendChild(host);
837
+ return root;
838
+ };
839
+ const showTip = (anchor, change) => {
840
+ if (tips.has(change.id)) return true;
841
+ if (readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
842
+ const shadow = ensureRoot();
843
+ if (!shadow) return false;
844
+ const card = document.createElement("div");
845
+ card.className = "tip";
846
+ card.setAttribute("role", "tooltip");
847
+ const text = document.createElement("p");
848
+ text.textContent = change.content.text;
849
+ const close = document.createElement("button");
850
+ close.type = "button";
851
+ close.setAttribute("aria-label", "Fechar");
852
+ close.textContent = "\xD7";
853
+ card.append(text, close);
854
+ card.style.top = "-9999px";
855
+ card.style.left = "-9999px";
856
+ shadow.appendChild(card);
857
+ const remove = () => {
858
+ card.remove();
859
+ window.removeEventListener("scroll", position, true);
860
+ window.removeEventListener("resize", position);
861
+ tips.delete(change.id);
862
+ };
863
+ function position() {
864
+ if (!anchor) {
865
+ const corner = card.getBoundingClientRect();
866
+ card.style.top = `${Math.max(8, window.innerHeight - corner.height - 16)}px`;
867
+ card.style.left = `${Math.max(8, window.innerWidth - corner.width - 16)}px`;
868
+ return;
869
+ }
870
+ if (!anchor.isConnected) {
871
+ remove();
872
+ return;
873
+ }
874
+ const a = anchor.getBoundingClientRect();
875
+ const c = card.getBoundingClientRect();
876
+ let top = a.bottom + TIP_GAP_PX;
877
+ if (top + c.height > window.innerHeight - 8) top = a.top - c.height - TIP_GAP_PX;
878
+ const left = Math.min(Math.max(8, a.left + a.width / 2 - c.width / 2), window.innerWidth - c.width - 8);
879
+ card.style.top = `${Math.max(8, top)}px`;
880
+ card.style.left = `${Math.max(8, left)}px`;
881
+ }
882
+ close.addEventListener("click", () => {
883
+ addToSet(storage("local"), DISMISSED_KEY, change.id);
884
+ opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
885
+ remove();
886
+ });
887
+ tips.set(change.id, remove);
888
+ requestAnimationFrame(position);
889
+ window.addEventListener("scroll", position, true);
890
+ window.addEventListener("resize", position);
891
+ return true;
892
+ };
893
+ const scan = () => {
894
+ timer = null;
895
+ const active = changesForPage(changes, location.pathname, opts.getAccountKey());
896
+ const activeIds = new Set(active.map((a) => a.change.id));
897
+ for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
898
+ for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
899
+ for (const { change, arm } of active) {
900
+ const el = findAnchor(change.anchor);
901
+ if (!el && change.anchor.kind !== "page") continue;
902
+ if (change.kind === "copy") {
903
+ if (!el) continue;
904
+ const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
905
+ if (shown) expose(change, arm);
906
+ } else if (arm === "holdout" || showTip(el, change)) {
907
+ expose(change, arm);
908
+ }
909
+ }
910
+ };
911
+ const schedule = () => {
912
+ if (timer === null) timer = setTimeout(scan, SCAN_DEBOUNCE_MS);
913
+ };
914
+ const refresh = async () => {
915
+ lastFetch = Date.now();
916
+ try {
917
+ const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
918
+ headers: { Authorization: `Bearer ${opts.apiKey}` }
919
+ });
920
+ if (!res.ok) {
921
+ log(`changes ${res.status}`);
922
+ return;
923
+ }
924
+ changes = parseChanges(await res.json());
925
+ schedule();
926
+ } catch (err) {
927
+ log(`changes network error: ${err.message}`);
928
+ }
929
+ };
930
+ const observer = typeof MutationObserver !== "undefined" ? new MutationObserver(() => {
931
+ if (changes.length > 0 || copies.size > 0 || tips.size > 0) schedule();
932
+ }) : null;
933
+ const observe = () => {
934
+ if (document.body) observer?.observe(document.body, { childList: true, subtree: true, characterData: true });
935
+ };
936
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", observe, { once: true });
937
+ else observe();
938
+ const interval = setInterval(() => void refresh(), REFRESH_MS);
939
+ void refresh();
940
+ return {
941
+ onNavigate: () => {
942
+ if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh();
943
+ schedule();
944
+ },
945
+ refresh,
946
+ teardown: () => {
947
+ clearInterval(interval);
948
+ if (timer !== null) clearTimeout(timer);
949
+ observer?.disconnect();
950
+ for (const id of [...copies.keys()]) restoreCopy(id);
951
+ for (const remove of [...tips.values()]) remove();
952
+ host?.remove();
953
+ host = null;
954
+ root = null;
955
+ changes = [];
956
+ }
957
+ };
958
+ }
959
+
317
960
  // src/web.ts
318
961
  var ANON_STORAGE_KEY = "holdy_anon_id";
319
962
  var HoldyWeb = class extends HoldyCore {
320
963
  teardownAutoCapture = null;
964
+ friction = null;
965
+ changes = null;
321
966
  constructor(opts) {
322
967
  super("sdk_web", opts);
323
968
  this.anonymousId = resolveAnonymousId();
@@ -327,9 +972,30 @@ var HoldyWeb = class extends HoldyCore {
327
972
  if (document.visibilityState === "hidden") void this.flush();
328
973
  });
329
974
  }
975
+ if (opts.friction !== false) {
976
+ const frictionOpts = typeof opts.friction === "object" ? opts.friction : {};
977
+ this.friction = installFrictionCapture({
978
+ ...frictionOpts,
979
+ ignoreUrls: [...frictionOpts.ignoreUrls ?? [], this.baseUrl],
980
+ onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
981
+ });
982
+ }
330
983
  if (opts.autoCapture !== false) {
331
984
  this.teardownAutoCapture = installAutoCapture({
332
- onNavigate: (kind) => this.page(void 0, { navigation: kind })
985
+ onNavigate: (kind) => {
986
+ this.page(void 0, { navigation: kind });
987
+ this.friction?.onNavigate(typeof location !== "undefined" ? location.pathname : "/");
988
+ this.changes?.onNavigate();
989
+ }
990
+ });
991
+ }
992
+ if (opts.productChanges !== false && typeof window !== "undefined") {
993
+ this.changes = installProductChanges({
994
+ baseUrl: this.baseUrl,
995
+ apiKey: this.apiKey,
996
+ getAccountKey: () => this.accountKey,
997
+ track: (name, properties) => this.track(name, properties),
998
+ debug: opts.debug
333
999
  });
334
1000
  }
335
1001
  }
@@ -347,6 +1013,10 @@ var HoldyWeb = class extends HoldyCore {
347
1013
  ...typeof document !== "undefined" && document.title ? { title: document.title } : {}
348
1014
  });
349
1015
  }
1016
+ group(accountKey, traits) {
1017
+ super.group(accountKey, traits);
1018
+ this.changes?.onNavigate();
1019
+ }
350
1020
  reset() {
351
1021
  this.userId = null;
352
1022
  this.accountKey = null;
@@ -355,11 +1025,16 @@ var HoldyWeb = class extends HoldyCore {
355
1025
  } catch {
356
1026
  }
357
1027
  this.anonymousId = resolveAnonymousId();
1028
+ this.changes?.onNavigate();
358
1029
  }
359
1030
  /** Flush final + desinstala o auto-capture (devolve `history` ao original). */
360
1031
  async shutdown() {
361
1032
  this.teardownAutoCapture?.();
362
1033
  this.teardownAutoCapture = null;
1034
+ this.friction?.teardown();
1035
+ this.friction = null;
1036
+ this.changes?.teardown();
1037
+ this.changes = null;
363
1038
  await super.shutdown();
364
1039
  }
365
1040
  environmentContext() {