@holdyapp/sdk 1.6.0 → 1.9.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/README.md +8 -1
- package/dist/changes.d.ts +16 -2
- package/dist/changes.d.ts.map +1 -1
- package/dist/core.d.ts +1 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/friction.d.ts +2 -1
- package/dist/friction.d.ts.map +1 -1
- package/dist/holdy.js +25 -4
- package/dist/holdy.js.map +4 -4
- package/dist/index.cjs +420 -36
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +420 -36
- package/dist/index.mjs.map +4 -4
- package/dist/server.cjs +3 -0
- package/dist/server.cjs.map +2 -2
- package/dist/server.mjs +3 -0
- package/dist/server.mjs.map +2 -2
- package/dist/skeleton.d.ts +3 -1
- package/dist/skeleton.d.ts.map +1 -1
- package/dist/theme.d.ts +43 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/tip-css.d.ts +9 -0
- package/dist/tip-css.d.ts.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/web-context.d.ts +1 -0
- package/dist/web-context.d.ts.map +1 -1
- package/dist/web.d.ts +2 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +420 -36
- package/dist/web.mjs.map +4 -4
- package/package.json +1 -1
- package/src/changes.dom.test.ts +221 -0
- package/src/changes.test.ts +38 -0
- package/src/changes.ts +299 -26
- package/src/core.test.ts +15 -0
- package/src/core.ts +3 -0
- package/src/friction.dom.test.ts +54 -0
- package/src/friction.test.ts +43 -0
- package/src/friction.ts +44 -7
- package/src/skeleton.ts +12 -3
- package/src/theme.test.ts +118 -0
- package/src/theme.ts +120 -0
- package/src/tip-css.ts +32 -0
- package/src/types.ts +6 -0
- package/src/web-context.test.ts +18 -0
- package/src/web-context.ts +8 -0
- package/src/web.ts +8 -2
package/dist/index.cjs
CHANGED
|
@@ -41,6 +41,7 @@ var HoldyCore = class {
|
|
|
41
41
|
flushAt;
|
|
42
42
|
debug;
|
|
43
43
|
source;
|
|
44
|
+
release;
|
|
44
45
|
queue = [];
|
|
45
46
|
timer = null;
|
|
46
47
|
flushing = null;
|
|
@@ -57,6 +58,7 @@ var HoldyCore = class {
|
|
|
57
58
|
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
58
59
|
this.flushAt = Math.max(1, opts.flushAt ?? DEFAULT_FLUSH_AT);
|
|
59
60
|
this.debug = opts.debug ?? false;
|
|
61
|
+
this.release = typeof opts.release === "string" && opts.release.trim() ? opts.release.trim().slice(0, 64) : null;
|
|
60
62
|
const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
61
63
|
if (intervalMs > 0) {
|
|
62
64
|
this.timer = setInterval(() => void this.flush(), intervalMs);
|
|
@@ -73,6 +75,7 @@ var HoldyCore = class {
|
|
|
73
75
|
const occurredAt = overrides?.occurredAt ? new Date(overrides.occurredAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
|
|
74
76
|
const context = {
|
|
75
77
|
...this.environmentContext(),
|
|
78
|
+
...this.release ? { release: this.release } : {},
|
|
76
79
|
...overrides?.context ?? {}
|
|
77
80
|
};
|
|
78
81
|
if (accountKey) context.account_external_id = accountKey;
|
|
@@ -374,11 +377,14 @@ var NavLoopDetector = class {
|
|
|
374
377
|
return null;
|
|
375
378
|
}
|
|
376
379
|
};
|
|
380
|
+
function tagOf(el) {
|
|
381
|
+
return typeof el?.tagName === "string" ? el.tagName.toLowerCase() : "";
|
|
382
|
+
}
|
|
377
383
|
function resolveInteractive(el) {
|
|
378
384
|
if (!el) return null;
|
|
379
385
|
const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null;
|
|
380
386
|
if (viaClosest) return viaClosest;
|
|
381
|
-
const tag = el
|
|
387
|
+
const tag = tagOf(el);
|
|
382
388
|
if (INTERACTIVE_TAGS.has(tag)) return el;
|
|
383
389
|
const role = el.getAttribute("role");
|
|
384
390
|
if (role === "button" || role === "link" || role === "menuitem" || role === "tab") return el;
|
|
@@ -390,7 +396,7 @@ function isDeadClickTarget(el) {
|
|
|
390
396
|
if (target.disabled === true) return true;
|
|
391
397
|
if (target.getAttribute("disabled") !== null) return true;
|
|
392
398
|
if (target.getAttribute("aria-disabled") === "true") return true;
|
|
393
|
-
const tag = target
|
|
399
|
+
const tag = tagOf(target);
|
|
394
400
|
if (tag === "a") {
|
|
395
401
|
const href = target.getAttribute("href");
|
|
396
402
|
if (!href || href === "#" || href.startsWith("javascript:")) return true;
|
|
@@ -400,7 +406,7 @@ function isDeadClickTarget(el) {
|
|
|
400
406
|
function describeElement(el) {
|
|
401
407
|
const target = resolveInteractive(el) ?? el;
|
|
402
408
|
if (!target) return {};
|
|
403
|
-
const out = { element: target
|
|
409
|
+
const out = { element: tagOf(target) };
|
|
404
410
|
if (target.id) out.element_id = target.id;
|
|
405
411
|
const role = target.getAttribute("role");
|
|
406
412
|
if (role) out.element_role = role;
|
|
@@ -428,6 +434,22 @@ function errorMessage(reason) {
|
|
|
428
434
|
return String(reason);
|
|
429
435
|
}
|
|
430
436
|
}
|
|
437
|
+
var STACK_MAX_FRAMES = 8;
|
|
438
|
+
var STACK_MAX_FRAME_CHARS = 200;
|
|
439
|
+
var STACK_FRAME = /^at\s|^\S*@\S+:\d+(?::\d+)?$/;
|
|
440
|
+
function sanitizeStack(stack) {
|
|
441
|
+
if (typeof stack !== "string" || !stack) return null;
|
|
442
|
+
const frames = stack.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && STACK_FRAME.test(line)).slice(0, STACK_MAX_FRAMES).map(
|
|
443
|
+
(line) => line.replace(/(https?:\/\/[^\s)?#]+)[?#][^\s)]*?(?=(?::\d+){0,2}\)?$)/, "$1").slice(0, STACK_MAX_FRAME_CHARS)
|
|
444
|
+
);
|
|
445
|
+
return frames.length > 0 ? frames.join("\n") : null;
|
|
446
|
+
}
|
|
447
|
+
function withStack(stack) {
|
|
448
|
+
return stack ? { stack } : {};
|
|
449
|
+
}
|
|
450
|
+
function stackOf(reason) {
|
|
451
|
+
return typeof reason === "object" && reason !== null ? sanitizeStack(reason.stack) : null;
|
|
452
|
+
}
|
|
431
453
|
function isAbortError(err) {
|
|
432
454
|
return typeof err === "object" && err !== null && err.name === "AbortError";
|
|
433
455
|
}
|
|
@@ -505,13 +527,19 @@ function installFrictionCapture(opts) {
|
|
|
505
527
|
emit("js_error", {
|
|
506
528
|
message: String(ev.message ?? "").slice(0, 200),
|
|
507
529
|
...ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {},
|
|
508
|
-
...ev.lineno ? { line: ev.lineno } : {}
|
|
530
|
+
...ev.lineno ? { line: ev.lineno } : {},
|
|
531
|
+
...ev.colno ? { column: ev.colno } : {},
|
|
532
|
+
...withStack(stackOf(ev.error))
|
|
509
533
|
});
|
|
510
534
|
};
|
|
511
535
|
window.addEventListener("error", onError);
|
|
512
536
|
cleanups.push(() => window.removeEventListener("error", onError));
|
|
513
537
|
const onRejection = (ev) => {
|
|
514
|
-
emit("js_error", {
|
|
538
|
+
emit("js_error", {
|
|
539
|
+
message: errorMessage(ev.reason).slice(0, 200),
|
|
540
|
+
unhandled_rejection: true,
|
|
541
|
+
...withStack(stackOf(ev.reason))
|
|
542
|
+
});
|
|
515
543
|
};
|
|
516
544
|
window.addEventListener("unhandledrejection", onRejection);
|
|
517
545
|
cleanups.push(() => window.removeEventListener("unhandledrejection", onRejection));
|
|
@@ -593,11 +621,11 @@ function installFrictionCapture(opts) {
|
|
|
593
621
|
}
|
|
594
622
|
const onFocusIn = (ev) => {
|
|
595
623
|
const el = ev.target;
|
|
596
|
-
if (!el || !FORM_CONTROL_TAGS.has(el
|
|
624
|
+
if (!el || !FORM_CONTROL_TAGS.has(tagOf(el))) return;
|
|
597
625
|
const form = el.form;
|
|
598
626
|
if (!form) return;
|
|
599
627
|
const entry = touched.get(form) ?? { form: formKey(form), fields: /* @__PURE__ */ new Set() };
|
|
600
|
-
const field = el.name || el.id || el
|
|
628
|
+
const field = el.name || el.id || tagOf(el);
|
|
601
629
|
entry.fields.add(field);
|
|
602
630
|
touched.set(form, entry);
|
|
603
631
|
};
|
|
@@ -630,6 +658,86 @@ function installFrictionCapture(opts) {
|
|
|
630
658
|
};
|
|
631
659
|
}
|
|
632
660
|
|
|
661
|
+
// src/theme.ts
|
|
662
|
+
var THEME_BUTTON_SELECTOR = 'button[type="submit"],[data-testid*="primary"],.btn-primary,button,[role="button"],a.button';
|
|
663
|
+
var THEME_MAX_BUTTONS = 40;
|
|
664
|
+
var THEME_KEYS = ["font", "size", "fg", "bg", "accent", "accentFg", "radius"];
|
|
665
|
+
var COLOR = /^(rgba?\([\d.,\s%/]{1,40}\)|#[0-9a-fA-F]{3,8}|(?:color|oklch|oklab|hsla?|lab|lch)\([^()]{1,60}\))$/;
|
|
666
|
+
var MIN_ACCENT_CONTRAST = 60;
|
|
667
|
+
var RGB = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/;
|
|
668
|
+
function colorDistance(a, b) {
|
|
669
|
+
const x = RGB.exec(a);
|
|
670
|
+
const y = b ? RGB.exec(b) : null;
|
|
671
|
+
if (!x) return MIN_ACCENT_CONTRAST + 1;
|
|
672
|
+
if (!y) return MIN_ACCENT_CONTRAST + 1;
|
|
673
|
+
return Math.abs(+x[1] - +y[1]) + Math.abs(+x[2] - +y[2]) + Math.abs(+x[3] - +y[3]);
|
|
674
|
+
}
|
|
675
|
+
var TRANSPARENT = /^(transparent|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)|rgba?\(\s*\d+\s+\d+\s+\d+\s*\/\s*0\s*\))$/;
|
|
676
|
+
function themeVarName(key) {
|
|
677
|
+
return `--holdy-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
678
|
+
}
|
|
679
|
+
function cleanColor(value) {
|
|
680
|
+
const v = (value ?? "").trim();
|
|
681
|
+
return v && v.length <= 80 && !TRANSPARENT.test(v) && COLOR.test(v) ? v : void 0;
|
|
682
|
+
}
|
|
683
|
+
function cleanFont(value) {
|
|
684
|
+
const first = (value ?? "").split(",")[0].trim().replace(/^["']|["']$/g, "").trim();
|
|
685
|
+
return /^[\w\s-]{1,60}$/.test(first) ? first : void 0;
|
|
686
|
+
}
|
|
687
|
+
function cleanPx(value, min, max) {
|
|
688
|
+
const m = /^(\d+(?:\.\d+)?)px/.exec((value ?? "").trim());
|
|
689
|
+
const n = m ? Math.round(Number(m[1])) : NaN;
|
|
690
|
+
return n >= min && n <= max ? `${n}px` : void 0;
|
|
691
|
+
}
|
|
692
|
+
function visible(el) {
|
|
693
|
+
const r = el.getBoundingClientRect?.();
|
|
694
|
+
return !r || r.width > 0 && r.height > 0;
|
|
695
|
+
}
|
|
696
|
+
function readTheme(doc, getStyle, anchor = null) {
|
|
697
|
+
const body = doc.body;
|
|
698
|
+
if (!body) return null;
|
|
699
|
+
const bodyStyle = getStyle(body);
|
|
700
|
+
const theme = {};
|
|
701
|
+
const put = (key, value) => {
|
|
702
|
+
if (value) theme[key] = value;
|
|
703
|
+
};
|
|
704
|
+
put("font", cleanFont(bodyStyle.getPropertyValue("font-family")));
|
|
705
|
+
put("size", cleanPx(bodyStyle.getPropertyValue("font-size"), 10, 20));
|
|
706
|
+
put("fg", cleanColor(bodyStyle.getPropertyValue("color")));
|
|
707
|
+
put("bg", cleanColor(bodyStyle.getPropertyValue("background-color")) ?? cleanColor(getStyle(doc.documentElement).getPropertyValue("background-color")));
|
|
708
|
+
const candidates = doc.querySelectorAll(THEME_BUTTON_SELECTOR);
|
|
709
|
+
let button = null;
|
|
710
|
+
let best = MIN_ACCENT_CONTRAST;
|
|
711
|
+
for (let i = 0; i < candidates.length && i < THEME_MAX_BUTTONS; i++) {
|
|
712
|
+
const el = candidates[i];
|
|
713
|
+
if (!visible(el)) continue;
|
|
714
|
+
const s = getStyle(el);
|
|
715
|
+
const accent = cleanColor(s.getPropertyValue("background-color"));
|
|
716
|
+
if (!accent || accent === theme.bg) continue;
|
|
717
|
+
const contrast = colorDistance(accent, theme.bg);
|
|
718
|
+
if (contrast <= best) continue;
|
|
719
|
+
best = contrast;
|
|
720
|
+
put("accent", accent);
|
|
721
|
+
put("accentFg", cleanColor(s.getPropertyValue("color")));
|
|
722
|
+
button = el;
|
|
723
|
+
}
|
|
724
|
+
const radiusSource = anchor && anchor.tagName.toLowerCase() === "button" ? anchor : button;
|
|
725
|
+
if (radiusSource) put("radius", cleanPx(getStyle(radiusSource).getPropertyValue("border-top-left-radius"), 0, 24));
|
|
726
|
+
return Object.keys(theme).length > 0 ? theme : null;
|
|
727
|
+
}
|
|
728
|
+
function themeVars(theme) {
|
|
729
|
+
const out = [];
|
|
730
|
+
for (const key of THEME_KEYS) {
|
|
731
|
+
const value = theme?.[key];
|
|
732
|
+
if (value) out.push([themeVarName(key), value]);
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
function applyThemeVars(el, theme) {
|
|
737
|
+
for (const key of THEME_KEYS) el.style.removeProperty(themeVarName(key));
|
|
738
|
+
for (const [name, value] of themeVars(theme)) el.style.setProperty(name, value);
|
|
739
|
+
}
|
|
740
|
+
|
|
633
741
|
// src/skeleton.ts
|
|
634
742
|
var SKELETON_SELECTOR = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]';
|
|
635
743
|
var SKELETON_MAX_ELEMENTS = 80;
|
|
@@ -676,18 +784,18 @@ function describeSkeletonElement(el, viewport) {
|
|
|
676
784
|
if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
|
|
677
785
|
return out;
|
|
678
786
|
}
|
|
679
|
-
function collectSkeleton(root, path, viewport) {
|
|
787
|
+
function collectSkeleton(root, path, viewport, theme = null) {
|
|
680
788
|
const found = root.querySelectorAll(SKELETON_SELECTOR);
|
|
681
|
-
const
|
|
789
|
+
const visible2 = [];
|
|
682
790
|
for (let i = 0; i < found.length; i++) {
|
|
683
791
|
const el = found[i];
|
|
684
792
|
if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
|
|
685
793
|
if (!isVisible(el)) continue;
|
|
686
|
-
|
|
794
|
+
visible2.push(el);
|
|
687
795
|
}
|
|
688
|
-
const elements =
|
|
796
|
+
const elements = visible2.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
|
|
689
797
|
const structural = elements.map(({ b: _b, ...rest }) => rest);
|
|
690
|
-
return { path, hash: fnv1a(JSON.stringify(structural)), count:
|
|
798
|
+
return { path, hash: fnv1a(JSON.stringify(structural)), count: visible2.length, elements, ...theme ? { theme } : {} };
|
|
691
799
|
}
|
|
692
800
|
function loadSent() {
|
|
693
801
|
try {
|
|
@@ -721,7 +829,8 @@ function installSkeletonCapture(opts) {
|
|
|
721
829
|
height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
|
|
722
830
|
scrollY: window.scrollY
|
|
723
831
|
};
|
|
724
|
-
const
|
|
832
|
+
const theme = readTheme(document, (el) => window.getComputedStyle(el));
|
|
833
|
+
const skeleton = collectSkeleton(document, path, viewport, theme);
|
|
725
834
|
const key = `${path}:${skeleton.hash}`;
|
|
726
835
|
if (sent.has(key)) return;
|
|
727
836
|
sent.add(key);
|
|
@@ -807,6 +916,12 @@ function randomId() {
|
|
|
807
916
|
return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
808
917
|
}
|
|
809
918
|
}
|
|
919
|
+
function clearSessionId() {
|
|
920
|
+
try {
|
|
921
|
+
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
922
|
+
} catch {
|
|
923
|
+
}
|
|
924
|
+
}
|
|
810
925
|
function resolveSessionId() {
|
|
811
926
|
const existing = readStorage("session", SESSION_STORAGE_KEY);
|
|
812
927
|
if (existing) return existing;
|
|
@@ -860,6 +975,33 @@ function buildWebContext() {
|
|
|
860
975
|
};
|
|
861
976
|
}
|
|
862
977
|
|
|
978
|
+
// src/tip-css.ts
|
|
979
|
+
var TIP_CSS = `:host{all:initial;--h-bg:#111;--h-fg:#fff;--h-accent:#fff;--h-accent-fg:#111;--h-border:transparent}
|
|
980
|
+
@media (prefers-color-scheme:dark){:host{--h-bg:#fff;--h-fg:#111;--h-accent:#111;--h-accent-fg:#fff}}
|
|
981
|
+
.tip{position:fixed;box-sizing:border-box;max-width:280px;padding:10px 12px;border-radius:var(--holdy-radius,8px);background:var(--holdy-bg,var(--h-bg));color:var(--holdy-fg,var(--h-fg));border:1px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 14%,transparent);font:var(--holdy-size,13px)/1.45 var(--holdy-font,system-ui),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}
|
|
982
|
+
.tip p{margin:0;flex:1}
|
|
983
|
+
.tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1;color:inherit}
|
|
984
|
+
.tip button:hover,.tip button:focus-visible{opacity:1}
|
|
985
|
+
.ask{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
|
|
986
|
+
.ask .head{display:flex;gap:10px;align-items:flex-start;width:100%}
|
|
987
|
+
.ask textarea{all:unset;box-sizing:border-box;width:100%;min-height:56px;padding:8px;border-radius:calc(var(--holdy-radius,8px) * .75);background:color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 8%,transparent);color:inherit;font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;cursor:text}
|
|
988
|
+
.ask textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 45%,transparent)}
|
|
989
|
+
.ask .send{align-self:flex-end;padding:5px 12px;border-radius:calc(var(--holdy-radius,8px) * .75);background:var(--holdy-accent,var(--h-accent));color:var(--holdy-accent-fg,var(--h-accent-fg));font-size:12.5px;font-weight:500;opacity:1}
|
|
990
|
+
.ask .send[disabled]{opacity:.4;cursor:default}
|
|
991
|
+
.list{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
|
|
992
|
+
.list .head{display:flex;gap:10px;align-items:flex-start;width:100%}
|
|
993
|
+
.list .head p{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
|
|
994
|
+
.list .count{opacity:.7;font-size:12px;white-space:nowrap}
|
|
995
|
+
.list ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;width:100%}
|
|
996
|
+
.list li{display:flex;gap:8px;align-items:flex-start}
|
|
997
|
+
.list li.done{opacity:.55;text-decoration:line-through}
|
|
998
|
+
.list .mark{flex:none;width:16px;height:16px;border-radius:4px;border:1px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 45%,transparent);font-size:11px;line-height:16px;text-align:center}
|
|
999
|
+
.list li.done .mark{background:var(--holdy-accent,var(--h-accent));color:var(--holdy-accent-fg,var(--h-accent-fg));border-color:var(--holdy-accent,var(--h-accent))}
|
|
1000
|
+
.list.folded ul{display:none}
|
|
1001
|
+
.list.folded{width:auto}
|
|
1002
|
+
.preview{flex:none;order:-1;width:100%;font-size:11px;opacity:.7}
|
|
1003
|
+
.tip:has(.preview){flex-wrap:wrap;row-gap:4px}`;
|
|
1004
|
+
|
|
863
1005
|
// src/arm.ts
|
|
864
1006
|
function fnv1a2(input) {
|
|
865
1007
|
let hash = 2166136261;
|
|
@@ -889,12 +1031,16 @@ var SCAN_DEBOUNCE_MS = 250;
|
|
|
889
1031
|
var SEEN_KEY = "holdy_changes_seen";
|
|
890
1032
|
var DISMISSED_KEY = "holdy_changes_dismissed";
|
|
891
1033
|
var TIP_GAP_PX = 8;
|
|
892
|
-
var
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1034
|
+
var ANSWERED_KEY = "holdy_questions_answered";
|
|
1035
|
+
var FOLDED_KEY = "holdy_checklist_folded";
|
|
1036
|
+
var COMPLETED_KEY = "holdy_checklist_completed";
|
|
1037
|
+
var PROGRESS_REFRESH_MS = 60 * 1e3;
|
|
1038
|
+
var VOICE_EVENT = "$voice";
|
|
1039
|
+
var MAX_ANSWER_CHARS = 1e3;
|
|
1040
|
+
var THANKS_MS = 1600;
|
|
1041
|
+
var PREVIEW_PARAM = "holdy_preview";
|
|
1042
|
+
var PREVIEW_KEY = "holdy_preview";
|
|
1043
|
+
var PREVIEW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
898
1044
|
function normalizeText(text) {
|
|
899
1045
|
return text.replace(/\s+/g, " ").trim();
|
|
900
1046
|
}
|
|
@@ -907,13 +1053,42 @@ function isAnchor(value) {
|
|
|
907
1053
|
if (a?.kind === "page") return true;
|
|
908
1054
|
return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
|
|
909
1055
|
}
|
|
1056
|
+
function isItem(value) {
|
|
1057
|
+
const i = value;
|
|
1058
|
+
return typeof i?.definition_name === "string" && typeof i.label === "string" && Array.isArray(i.events) && i.events.every((e) => typeof e === "string");
|
|
1059
|
+
}
|
|
910
1060
|
function isChange(value) {
|
|
911
1061
|
const c = value;
|
|
912
|
-
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";
|
|
1062
|
+
return typeof c?.id === "string" && (c.kind === "copy" || c.kind === "guidance" || c.kind === "question" && typeof c.plan_id === "string" || c.kind === "checklist" && Array.isArray(c.content?.items) && c.content.items.length > 0 && c.content.items.every(isItem)) && typeof c.path === "string" && isAnchor(c.anchor) && typeof c.content?.text === "string" && typeof c.holdout_pct === "number";
|
|
913
1063
|
}
|
|
914
|
-
function changesForPage(changes, path, accountKey) {
|
|
915
|
-
|
|
916
|
-
|
|
1064
|
+
function changesForPage(changes, path, accountKey, previewId = null) {
|
|
1065
|
+
return changes.filter((c) => c.kind === "checklist" || matchesPathTemplate(c.path, path)).flatMap((change) => {
|
|
1066
|
+
if (change.id === previewId) return [{ change, arm: "change", preview: true }];
|
|
1067
|
+
if (!accountKey) return [];
|
|
1068
|
+
return [{ change, arm: changeArm(change.id, accountKey, change.holdout_pct), preview: false }];
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
function resolvePreviewId(search, store) {
|
|
1072
|
+
let asked = null;
|
|
1073
|
+
try {
|
|
1074
|
+
asked = new URLSearchParams(search).get(PREVIEW_PARAM);
|
|
1075
|
+
} catch {
|
|
1076
|
+
asked = null;
|
|
1077
|
+
}
|
|
1078
|
+
try {
|
|
1079
|
+
if (asked !== null) {
|
|
1080
|
+
if (PREVIEW_ID.test(asked)) {
|
|
1081
|
+
store?.setItem(PREVIEW_KEY, asked);
|
|
1082
|
+
return asked;
|
|
1083
|
+
}
|
|
1084
|
+
store?.removeItem(PREVIEW_KEY);
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
const kept = store?.getItem(PREVIEW_KEY) ?? null;
|
|
1088
|
+
return kept && PREVIEW_ID.test(kept) ? kept : null;
|
|
1089
|
+
} catch {
|
|
1090
|
+
return asked !== null && PREVIEW_ID.test(asked) ? asked : null;
|
|
1091
|
+
}
|
|
917
1092
|
}
|
|
918
1093
|
function storage(kind) {
|
|
919
1094
|
try {
|
|
@@ -975,7 +1150,7 @@ function findTextNode(el, match) {
|
|
|
975
1150
|
}
|
|
976
1151
|
function installProductChanges(opts) {
|
|
977
1152
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
978
|
-
return { onNavigate: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
|
|
1153
|
+
return { onNavigate: () => void 0, onEvent: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
|
|
979
1154
|
}
|
|
980
1155
|
let changes = [];
|
|
981
1156
|
let lastFetch = 0;
|
|
@@ -984,6 +1159,11 @@ function installProductChanges(opts) {
|
|
|
984
1159
|
let host = null;
|
|
985
1160
|
const copies = /* @__PURE__ */ new Map();
|
|
986
1161
|
const tips = /* @__PURE__ */ new Map();
|
|
1162
|
+
const progress = /* @__PURE__ */ new Map();
|
|
1163
|
+
const progressAt = /* @__PURE__ */ new Map();
|
|
1164
|
+
const lists = /* @__PURE__ */ new Map();
|
|
1165
|
+
let fetchedForKey = null;
|
|
1166
|
+
let previewId = resolvePreviewId(location.search, storage("session"));
|
|
987
1167
|
const log = (message) => {
|
|
988
1168
|
if (opts.debug) console.warn(`[holdy] ${message}`);
|
|
989
1169
|
};
|
|
@@ -1033,21 +1213,66 @@ function installProductChanges(opts) {
|
|
|
1033
1213
|
document.body.appendChild(host);
|
|
1034
1214
|
return root;
|
|
1035
1215
|
};
|
|
1036
|
-
|
|
1216
|
+
let dismissedPreview = false;
|
|
1217
|
+
const previewTag = () => {
|
|
1218
|
+
const tag = document.createElement("span");
|
|
1219
|
+
tag.className = "preview";
|
|
1220
|
+
tag.textContent = "Pr\xE9via, s\xF3 voc\xEA v\xEA";
|
|
1221
|
+
return tag;
|
|
1222
|
+
};
|
|
1223
|
+
const showTip = (anchor, change, preview) => {
|
|
1037
1224
|
if (tips.has(change.id)) return true;
|
|
1038
|
-
if (readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
|
|
1225
|
+
if (!preview && readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
|
|
1226
|
+
const asking = change.kind === "question";
|
|
1227
|
+
if (!preview && asking && readSet(storage("local"), ANSWERED_KEY).has(change.id)) return true;
|
|
1039
1228
|
const shadow = ensureRoot();
|
|
1040
1229
|
if (!shadow) return false;
|
|
1230
|
+
if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el), anchor));
|
|
1041
1231
|
const card = document.createElement("div");
|
|
1042
|
-
card.className = "tip";
|
|
1043
|
-
card.setAttribute("role", "tooltip");
|
|
1232
|
+
card.className = asking ? "tip ask" : "tip";
|
|
1233
|
+
card.setAttribute("role", asking ? "dialog" : "tooltip");
|
|
1044
1234
|
const text = document.createElement("p");
|
|
1045
1235
|
text.textContent = change.content.text;
|
|
1046
1236
|
const close = document.createElement("button");
|
|
1047
1237
|
close.type = "button";
|
|
1048
1238
|
close.setAttribute("aria-label", "Fechar");
|
|
1049
1239
|
close.textContent = "\xD7";
|
|
1050
|
-
|
|
1240
|
+
if (asking) {
|
|
1241
|
+
const head = document.createElement("div");
|
|
1242
|
+
head.className = "head";
|
|
1243
|
+
head.append(text, close);
|
|
1244
|
+
const input = document.createElement("textarea");
|
|
1245
|
+
input.maxLength = MAX_ANSWER_CHARS;
|
|
1246
|
+
input.setAttribute("aria-label", change.content.text);
|
|
1247
|
+
const send = document.createElement("button");
|
|
1248
|
+
send.type = "button";
|
|
1249
|
+
send.className = "send";
|
|
1250
|
+
send.textContent = "Enviar";
|
|
1251
|
+
send.disabled = true;
|
|
1252
|
+
input.addEventListener("input", () => {
|
|
1253
|
+
send.disabled = input.value.trim().length === 0;
|
|
1254
|
+
});
|
|
1255
|
+
send.addEventListener("click", () => {
|
|
1256
|
+
const answer = input.value.trim().slice(0, MAX_ANSWER_CHARS);
|
|
1257
|
+
if (!answer) return;
|
|
1258
|
+
if (!preview) {
|
|
1259
|
+
addToSet(storage("local"), ANSWERED_KEY, change.id);
|
|
1260
|
+
opts.track(VOICE_EVENT, {
|
|
1261
|
+
question: change.content.text,
|
|
1262
|
+
text: answer,
|
|
1263
|
+
action_plan_id: change.plan_id,
|
|
1264
|
+
change_id: change.id,
|
|
1265
|
+
path: location.pathname
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
card.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Obrigado pela resposta." }));
|
|
1269
|
+
setTimeout(remove, THANKS_MS);
|
|
1270
|
+
});
|
|
1271
|
+
card.append(head, input, send);
|
|
1272
|
+
} else {
|
|
1273
|
+
card.append(text, close);
|
|
1274
|
+
}
|
|
1275
|
+
if (preview) card.prepend(previewTag());
|
|
1051
1276
|
card.style.top = "-9999px";
|
|
1052
1277
|
card.style.left = "-9999px";
|
|
1053
1278
|
shadow.appendChild(card);
|
|
@@ -1077,6 +1302,11 @@ function installProductChanges(opts) {
|
|
|
1077
1302
|
card.style.left = `${Math.max(8, left)}px`;
|
|
1078
1303
|
}
|
|
1079
1304
|
close.addEventListener("click", () => {
|
|
1305
|
+
if (preview) {
|
|
1306
|
+
dismissedPreview = true;
|
|
1307
|
+
remove();
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1080
1310
|
addToSet(storage("local"), DISMISSED_KEY, change.id);
|
|
1081
1311
|
opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
|
|
1082
1312
|
remove();
|
|
@@ -1087,20 +1317,143 @@ function installProductChanges(opts) {
|
|
|
1087
1317
|
window.addEventListener("resize", position);
|
|
1088
1318
|
return true;
|
|
1089
1319
|
};
|
|
1320
|
+
const doneOf = (change) => progress.get(change.id) ?? /* @__PURE__ */ new Set();
|
|
1321
|
+
const allDone = (change) => {
|
|
1322
|
+
const done = doneOf(change);
|
|
1323
|
+
return (change.content.items ?? []).every((i) => done.has(i.definition_name));
|
|
1324
|
+
};
|
|
1325
|
+
const fetchProgress = async (change) => {
|
|
1326
|
+
const account = opts.getAccountKey();
|
|
1327
|
+
if (!account) return;
|
|
1328
|
+
const last = progressAt.get(change.id) ?? 0;
|
|
1329
|
+
if (Date.now() - last < PROGRESS_REFRESH_MS) return;
|
|
1330
|
+
progressAt.set(change.id, Date.now());
|
|
1331
|
+
try {
|
|
1332
|
+
const res = await fetch(
|
|
1333
|
+
`${opts.baseUrl}/runtime/checklist?change_id=${encodeURIComponent(change.id)}&account=${encodeURIComponent(account)}`,
|
|
1334
|
+
{ headers: { Authorization: `Bearer ${opts.apiKey}` } }
|
|
1335
|
+
);
|
|
1336
|
+
if (!res.ok) return;
|
|
1337
|
+
const body = await res.json();
|
|
1338
|
+
const done = Array.isArray(body.done) ? body.done.filter((d) => typeof d === "string") : [];
|
|
1339
|
+
const set = doneOf(change);
|
|
1340
|
+
for (const d of done) set.add(d);
|
|
1341
|
+
progress.set(change.id, set);
|
|
1342
|
+
lists.get(change.id)?.();
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
log(`checklist progress error: ${err.message}`);
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
const showChecklist = (change, preview) => {
|
|
1348
|
+
if (lists.has(change.id)) return true;
|
|
1349
|
+
if (!preview && readSet(storage("local"), COMPLETED_KEY).has(change.id)) return true;
|
|
1350
|
+
const shadow = ensureRoot();
|
|
1351
|
+
if (!shadow) return false;
|
|
1352
|
+
const items = change.content.items ?? [];
|
|
1353
|
+
const card = document.createElement("div");
|
|
1354
|
+
card.className = "tip list";
|
|
1355
|
+
card.setAttribute("role", "region");
|
|
1356
|
+
card.setAttribute("aria-label", change.content.text);
|
|
1357
|
+
const head = document.createElement("div");
|
|
1358
|
+
head.className = "head";
|
|
1359
|
+
const title = document.createElement("p");
|
|
1360
|
+
const strong = document.createElement("strong");
|
|
1361
|
+
strong.textContent = change.content.text;
|
|
1362
|
+
const count = document.createElement("span");
|
|
1363
|
+
count.className = "count";
|
|
1364
|
+
title.append(strong, count);
|
|
1365
|
+
const fold = document.createElement("button");
|
|
1366
|
+
fold.type = "button";
|
|
1367
|
+
head.append(title, fold);
|
|
1368
|
+
const list = document.createElement("ul");
|
|
1369
|
+
card.append(head, list);
|
|
1370
|
+
if (preview) card.prepend(previewTag());
|
|
1371
|
+
card.style.position = "fixed";
|
|
1372
|
+
card.style.right = "16px";
|
|
1373
|
+
card.style.bottom = "16px";
|
|
1374
|
+
shadow.appendChild(card);
|
|
1375
|
+
const remove = () => {
|
|
1376
|
+
card.remove();
|
|
1377
|
+
lists.delete(change.id);
|
|
1378
|
+
};
|
|
1379
|
+
const render = () => {
|
|
1380
|
+
const done = doneOf(change);
|
|
1381
|
+
const n = items.filter((i) => done.has(i.definition_name)).length;
|
|
1382
|
+
count.textContent = `${n} de ${items.length}`;
|
|
1383
|
+
const folded = readSet(storage("local"), FOLDED_KEY).has(change.id);
|
|
1384
|
+
card.classList.toggle("folded", folded);
|
|
1385
|
+
fold.textContent = folded ? "+" : "\u2013";
|
|
1386
|
+
fold.setAttribute("aria-label", folded ? "Abrir" : "Recolher");
|
|
1387
|
+
list.replaceChildren(
|
|
1388
|
+
...items.map((item) => {
|
|
1389
|
+
const li = document.createElement("li");
|
|
1390
|
+
const isDone = done.has(item.definition_name);
|
|
1391
|
+
if (isDone) li.className = "done";
|
|
1392
|
+
const mark = document.createElement("span");
|
|
1393
|
+
mark.className = "mark";
|
|
1394
|
+
mark.setAttribute("aria-hidden", "true");
|
|
1395
|
+
mark.textContent = isDone ? "\u2713" : "";
|
|
1396
|
+
const label = document.createElement("span");
|
|
1397
|
+
label.textContent = item.label;
|
|
1398
|
+
li.append(mark, label);
|
|
1399
|
+
return li;
|
|
1400
|
+
})
|
|
1401
|
+
);
|
|
1402
|
+
if (!preview && n === items.length && items.length > 0) {
|
|
1403
|
+
addToSet(storage("local"), COMPLETED_KEY, change.id);
|
|
1404
|
+
opts.track("$checklist_completed", { change_id: change.id });
|
|
1405
|
+
remove();
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
fold.addEventListener("click", () => {
|
|
1409
|
+
const store = storage("local");
|
|
1410
|
+
const set = readSet(store, FOLDED_KEY);
|
|
1411
|
+
if (set.has(change.id)) set.delete(change.id);
|
|
1412
|
+
else set.add(change.id);
|
|
1413
|
+
try {
|
|
1414
|
+
store?.setItem(FOLDED_KEY, JSON.stringify([...set].slice(-200)));
|
|
1415
|
+
} catch {
|
|
1416
|
+
}
|
|
1417
|
+
render();
|
|
1418
|
+
});
|
|
1419
|
+
lists.set(change.id, render);
|
|
1420
|
+
render();
|
|
1421
|
+
void fetchProgress(change);
|
|
1422
|
+
return true;
|
|
1423
|
+
};
|
|
1090
1424
|
const scan = () => {
|
|
1091
1425
|
timer = null;
|
|
1092
|
-
const active = changesForPage(changes, location.pathname, opts.getAccountKey());
|
|
1426
|
+
const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId);
|
|
1093
1427
|
const activeIds = new Set(active.map((a) => a.change.id));
|
|
1094
1428
|
for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
|
|
1095
1429
|
for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
|
|
1096
|
-
for (const
|
|
1430
|
+
for (const [id, render] of [...lists]) {
|
|
1431
|
+
if (!activeIds.has(id)) {
|
|
1432
|
+
lists.delete(id);
|
|
1433
|
+
render();
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
for (const { change, arm, preview } of active) {
|
|
1097
1437
|
const el = findAnchor(change.anchor);
|
|
1098
|
-
if (!el && change.anchor.kind !== "page")
|
|
1099
|
-
|
|
1438
|
+
if (!el && change.anchor.kind !== "page") {
|
|
1439
|
+
if (preview) log(`preview: \xE2ncora n\xE3o encontrada em ${location.pathname}`);
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
if (preview) {
|
|
1443
|
+
if (change.kind === "checklist") showChecklist(change, true);
|
|
1444
|
+
else if (change.kind === "copy") {
|
|
1445
|
+
if (el) applyCopy(el, change);
|
|
1446
|
+
} else if (!dismissedPreview) showTip(el, change, true);
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
if (change.kind === "checklist") {
|
|
1450
|
+
if (arm === "holdout" || allDone(change) || showChecklist(change, false)) expose(change, arm);
|
|
1451
|
+
if (arm === "change") void fetchProgress(change);
|
|
1452
|
+
} else if (change.kind === "copy") {
|
|
1100
1453
|
if (!el) continue;
|
|
1101
1454
|
const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
|
|
1102
1455
|
if (shown) expose(change, arm);
|
|
1103
|
-
} else if (arm === "holdout" || showTip(el, change)) {
|
|
1456
|
+
} else if (arm === "holdout" || showTip(el, change, false)) {
|
|
1104
1457
|
expose(change, arm);
|
|
1105
1458
|
}
|
|
1106
1459
|
}
|
|
@@ -1110,8 +1463,14 @@ function installProductChanges(opts) {
|
|
|
1110
1463
|
};
|
|
1111
1464
|
const refresh = async () => {
|
|
1112
1465
|
lastFetch = Date.now();
|
|
1466
|
+
const account = opts.getAccountKey();
|
|
1467
|
+
fetchedForKey = account;
|
|
1468
|
+
const params = new URLSearchParams();
|
|
1469
|
+
if (account) params.set("account", account);
|
|
1470
|
+
if (previewId) params.set("preview", previewId);
|
|
1471
|
+
const qs = params.toString();
|
|
1113
1472
|
try {
|
|
1114
|
-
const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
|
|
1473
|
+
const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ""}`, {
|
|
1115
1474
|
headers: { Authorization: `Bearer ${opts.apiKey}` }
|
|
1116
1475
|
});
|
|
1117
1476
|
if (!res.ok) {
|
|
@@ -1136,9 +1495,28 @@ function installProductChanges(opts) {
|
|
|
1136
1495
|
void refresh();
|
|
1137
1496
|
return {
|
|
1138
1497
|
onNavigate: () => {
|
|
1139
|
-
|
|
1498
|
+
const asked = resolvePreviewId(location.search, storage("session"));
|
|
1499
|
+
const previewChanged = asked !== previewId;
|
|
1500
|
+
if (previewChanged) {
|
|
1501
|
+
previewId = asked;
|
|
1502
|
+
dismissedPreview = false;
|
|
1503
|
+
for (const id of [...copies.keys()]) restoreCopy(id);
|
|
1504
|
+
for (const remove of [...tips.values()]) remove();
|
|
1505
|
+
}
|
|
1506
|
+
if (previewChanged || Date.now() - lastFetch > NAV_REFRESH_MS || opts.getAccountKey() !== fetchedForKey) void refresh();
|
|
1140
1507
|
schedule();
|
|
1141
1508
|
},
|
|
1509
|
+
onEvent: (name) => {
|
|
1510
|
+
for (const change of changes) {
|
|
1511
|
+
if (change.kind !== "checklist") continue;
|
|
1512
|
+
const hit = (change.content.items ?? []).filter((i) => i.events.includes(name));
|
|
1513
|
+
if (hit.length === 0) continue;
|
|
1514
|
+
const set = doneOf(change);
|
|
1515
|
+
for (const i of hit) set.add(i.definition_name);
|
|
1516
|
+
progress.set(change.id, set);
|
|
1517
|
+
lists.get(change.id)?.();
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1142
1520
|
refresh,
|
|
1143
1521
|
teardown: () => {
|
|
1144
1522
|
clearInterval(interval);
|
|
@@ -1146,6 +1524,7 @@ function installProductChanges(opts) {
|
|
|
1146
1524
|
observer?.disconnect();
|
|
1147
1525
|
for (const id of [...copies.keys()]) restoreCopy(id);
|
|
1148
1526
|
for (const remove of [...tips.values()]) remove();
|
|
1527
|
+
for (const id of [...lists.keys()]) lists.delete(id);
|
|
1149
1528
|
host?.remove();
|
|
1150
1529
|
host = null;
|
|
1151
1530
|
root = null;
|
|
@@ -1222,6 +1601,10 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
1222
1601
|
super.group(accountKey, traits);
|
|
1223
1602
|
this.changes?.onNavigate();
|
|
1224
1603
|
}
|
|
1604
|
+
track(name, properties, overrides) {
|
|
1605
|
+
super.track(name, properties, overrides);
|
|
1606
|
+
if (!name.startsWith("$")) this.changes?.onEvent(name);
|
|
1607
|
+
}
|
|
1225
1608
|
reset() {
|
|
1226
1609
|
this.userId = null;
|
|
1227
1610
|
this.accountKey = null;
|
|
@@ -1229,6 +1612,7 @@ var HoldyWeb = class extends HoldyCore {
|
|
|
1229
1612
|
localStorage.removeItem(ANON_STORAGE_KEY);
|
|
1230
1613
|
} catch {
|
|
1231
1614
|
}
|
|
1615
|
+
clearSessionId();
|
|
1232
1616
|
this.anonymousId = resolveAnonymousId();
|
|
1233
1617
|
this.changes?.onNavigate();
|
|
1234
1618
|
}
|