@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/src/changes.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TIP_CSS } from './tip-css'
|
|
2
|
+
import { applyThemeVars, readTheme } from './theme'
|
|
1
3
|
import { changeArm, matchesPathTemplate, type ChangeArm } from './arm'
|
|
2
4
|
|
|
3
5
|
export type ChangeAnchor =
|
|
@@ -5,14 +7,21 @@ export type ChangeAnchor =
|
|
|
5
7
|
| { kind: 'text'; value: string }
|
|
6
8
|
| { kind: 'page' }
|
|
7
9
|
|
|
10
|
+
export interface ChecklistItem {
|
|
11
|
+
definition_name: string
|
|
12
|
+
label: string
|
|
13
|
+
events: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export interface ProductChange {
|
|
9
17
|
id: string
|
|
10
|
-
kind: 'copy' | 'guidance'
|
|
18
|
+
kind: 'copy' | 'guidance' | 'question' | 'checklist'
|
|
11
19
|
path: string
|
|
12
20
|
anchor: ChangeAnchor
|
|
13
21
|
expected_text: string | null
|
|
14
|
-
content: { text: string }
|
|
22
|
+
content: { text: string; items?: ChecklistItem[] }
|
|
15
23
|
holdout_pct: number
|
|
24
|
+
plan_id?: string
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export interface ProductChangesOptions {
|
|
@@ -25,6 +34,8 @@ export interface ProductChangesOptions {
|
|
|
25
34
|
|
|
26
35
|
export interface ProductChangesHandle {
|
|
27
36
|
onNavigate(): void
|
|
37
|
+
/** Evento do produto: marca na hora o passo do checklist que ele satisfaz; o servidor confirma depois. */
|
|
38
|
+
onEvent(name: string): void
|
|
28
39
|
refresh(): Promise<void>
|
|
29
40
|
teardown(): void
|
|
30
41
|
}
|
|
@@ -35,13 +46,17 @@ const SCAN_DEBOUNCE_MS = 250
|
|
|
35
46
|
const SEEN_KEY = 'holdy_changes_seen'
|
|
36
47
|
const DISMISSED_KEY = 'holdy_changes_dismissed'
|
|
37
48
|
const TIP_GAP_PX = 8
|
|
49
|
+
const ANSWERED_KEY = 'holdy_questions_answered'
|
|
50
|
+
const FOLDED_KEY = 'holdy_checklist_folded'
|
|
51
|
+
const COMPLETED_KEY = 'holdy_checklist_completed'
|
|
52
|
+
const PROGRESS_REFRESH_MS = 60 * 1000
|
|
53
|
+
export const VOICE_EVENT = '$voice'
|
|
54
|
+
export const MAX_ANSWER_CHARS = 1000
|
|
55
|
+
const THANKS_MS = 1600
|
|
56
|
+
export const PREVIEW_PARAM = 'holdy_preview'
|
|
57
|
+
const PREVIEW_KEY = 'holdy_preview'
|
|
58
|
+
const PREVIEW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
38
59
|
|
|
39
|
-
const TIP_CSS = `:host{all:initial}
|
|
40
|
-
.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}
|
|
41
|
-
.tip p{margin:0;flex:1}
|
|
42
|
-
.tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
|
|
43
|
-
.tip button:hover,.tip button:focus-visible{opacity:1}
|
|
44
|
-
@media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`
|
|
45
60
|
|
|
46
61
|
export function normalizeText(text: string): string {
|
|
47
62
|
return text.replace(/\s+/g, ' ').trim()
|
|
@@ -58,11 +73,24 @@ function isAnchor(value: unknown): value is ChangeAnchor {
|
|
|
58
73
|
return (a?.kind === 'selector' || a?.kind === 'text') && typeof a.value === 'string' && a.value.length > 0
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
function isItem(value: unknown): value is ChecklistItem {
|
|
77
|
+
const i = value as ChecklistItem | null
|
|
78
|
+
return (
|
|
79
|
+
typeof i?.definition_name === 'string' &&
|
|
80
|
+
typeof i.label === 'string' &&
|
|
81
|
+
Array.isArray(i.events) &&
|
|
82
|
+
i.events.every((e) => typeof e === 'string')
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
function isChange(value: unknown): value is ProductChange {
|
|
62
87
|
const c = value as ProductChange | null
|
|
63
88
|
return (
|
|
64
89
|
typeof c?.id === 'string' &&
|
|
65
|
-
(c.kind === 'copy' ||
|
|
90
|
+
(c.kind === 'copy' ||
|
|
91
|
+
c.kind === 'guidance' ||
|
|
92
|
+
(c.kind === 'question' && typeof c.plan_id === 'string') ||
|
|
93
|
+
(c.kind === 'checklist' && Array.isArray(c.content?.items) && c.content.items.length > 0 && c.content.items.every(isItem))) &&
|
|
66
94
|
typeof c.path === 'string' &&
|
|
67
95
|
isAnchor(c.anchor) &&
|
|
68
96
|
typeof c.content?.text === 'string' &&
|
|
@@ -74,11 +102,40 @@ export function changesForPage(
|
|
|
74
102
|
changes: readonly ProductChange[],
|
|
75
103
|
path: string,
|
|
76
104
|
accountKey: string | null,
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
previewId: string | null = null,
|
|
106
|
+
): Array<{ change: ProductChange; arm: ChangeArm; preview: boolean }> {
|
|
107
|
+
// O checklist acompanha a conta pelo produto inteiro: o molde de página é
|
|
108
|
+
// só onde ele nasceu, não onde aparece.
|
|
79
109
|
return changes
|
|
80
|
-
.filter((c) => matchesPathTemplate(c.path, path))
|
|
81
|
-
.
|
|
110
|
+
.filter((c) => c.kind === 'checklist' || matchesPathTemplate(c.path, path))
|
|
111
|
+
.flatMap((change): Array<{ change: ProductChange; arm: ChangeArm; preview: boolean }> => {
|
|
112
|
+
if (change.id === previewId) return [{ change, arm: 'change', preview: true }]
|
|
113
|
+
if (!accountKey) return []
|
|
114
|
+
return [{ change, arm: changeArm(change.id, accountKey, change.holdout_pct), preview: false }]
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function resolvePreviewId(search: string, store: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | undefined): string | null {
|
|
119
|
+
let asked: string | null = null
|
|
120
|
+
try {
|
|
121
|
+
asked = new URLSearchParams(search).get(PREVIEW_PARAM)
|
|
122
|
+
} catch {
|
|
123
|
+
asked = null
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
if (asked !== null) {
|
|
127
|
+
if (PREVIEW_ID.test(asked)) {
|
|
128
|
+
store?.setItem(PREVIEW_KEY, asked)
|
|
129
|
+
return asked
|
|
130
|
+
}
|
|
131
|
+
store?.removeItem(PREVIEW_KEY)
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
const kept = store?.getItem(PREVIEW_KEY) ?? null
|
|
135
|
+
return kept && PREVIEW_ID.test(kept) ? kept : null
|
|
136
|
+
} catch {
|
|
137
|
+
return asked !== null && PREVIEW_ID.test(asked) ? asked : null
|
|
138
|
+
}
|
|
82
139
|
}
|
|
83
140
|
|
|
84
141
|
function storage(kind: 'local' | 'session'): Storage | undefined {
|
|
@@ -155,7 +212,7 @@ interface AppliedCopy {
|
|
|
155
212
|
|
|
156
213
|
export function installProductChanges(opts: ProductChangesOptions): ProductChangesHandle {
|
|
157
214
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
158
|
-
return { onNavigate: () => undefined, refresh: async () => undefined, teardown: () => undefined }
|
|
215
|
+
return { onNavigate: () => undefined, onEvent: () => undefined, refresh: async () => undefined, teardown: () => undefined }
|
|
159
216
|
}
|
|
160
217
|
|
|
161
218
|
let changes: ProductChange[] = []
|
|
@@ -165,6 +222,11 @@ export function installProductChanges(opts: ProductChangesOptions): ProductChang
|
|
|
165
222
|
let host: HTMLElement | null = null
|
|
166
223
|
const copies = new Map<string, AppliedCopy>()
|
|
167
224
|
const tips = new Map<string, () => void>()
|
|
225
|
+
const progress = new Map<string, Set<string>>()
|
|
226
|
+
const progressAt = new Map<string, number>()
|
|
227
|
+
const lists = new Map<string, () => void>()
|
|
228
|
+
let fetchedForKey: string | null = null
|
|
229
|
+
let previewId = resolvePreviewId(location.search, storage('session'))
|
|
168
230
|
|
|
169
231
|
const log = (message: string): void => {
|
|
170
232
|
if (opts.debug) console.warn(`[holdy] ${message}`)
|
|
@@ -221,22 +283,69 @@ export function installProductChanges(opts: ProductChangesOptions): ProductChang
|
|
|
221
283
|
return root
|
|
222
284
|
}
|
|
223
285
|
|
|
224
|
-
|
|
286
|
+
let dismissedPreview = false
|
|
287
|
+
|
|
288
|
+
const previewTag = (): HTMLElement => {
|
|
289
|
+
const tag = document.createElement('span')
|
|
290
|
+
tag.className = 'preview'
|
|
291
|
+
tag.textContent = 'Prévia, só você vê'
|
|
292
|
+
return tag
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const showTip = (anchor: Element | null, change: ProductChange, preview: boolean): boolean => {
|
|
225
296
|
if (tips.has(change.id)) return true
|
|
226
|
-
if (readSet(storage('local'), DISMISSED_KEY).has(change.id)) return true
|
|
297
|
+
if (!preview && readSet(storage('local'), DISMISSED_KEY).has(change.id)) return true
|
|
298
|
+
const asking = change.kind === 'question'
|
|
299
|
+
if (!preview && asking && readSet(storage('local'), ANSWERED_KEY).has(change.id)) return true
|
|
227
300
|
const shadow = ensureRoot()
|
|
228
301
|
if (!shadow) return false
|
|
302
|
+
if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el as Element), anchor))
|
|
229
303
|
|
|
230
304
|
const card = document.createElement('div')
|
|
231
|
-
card.className = 'tip'
|
|
232
|
-
card.setAttribute('role', 'tooltip')
|
|
305
|
+
card.className = asking ? 'tip ask' : 'tip'
|
|
306
|
+
card.setAttribute('role', asking ? 'dialog' : 'tooltip')
|
|
233
307
|
const text = document.createElement('p')
|
|
234
308
|
text.textContent = change.content.text
|
|
235
309
|
const close = document.createElement('button')
|
|
236
310
|
close.type = 'button'
|
|
237
311
|
close.setAttribute('aria-label', 'Fechar')
|
|
238
312
|
close.textContent = '×'
|
|
239
|
-
|
|
313
|
+
if (asking) {
|
|
314
|
+
const head = document.createElement('div')
|
|
315
|
+
head.className = 'head'
|
|
316
|
+
head.append(text, close)
|
|
317
|
+
const input = document.createElement('textarea')
|
|
318
|
+
input.maxLength = MAX_ANSWER_CHARS
|
|
319
|
+
input.setAttribute('aria-label', change.content.text)
|
|
320
|
+
const send = document.createElement('button')
|
|
321
|
+
send.type = 'button'
|
|
322
|
+
send.className = 'send'
|
|
323
|
+
send.textContent = 'Enviar'
|
|
324
|
+
send.disabled = true
|
|
325
|
+
input.addEventListener('input', () => {
|
|
326
|
+
send.disabled = input.value.trim().length === 0
|
|
327
|
+
})
|
|
328
|
+
send.addEventListener('click', () => {
|
|
329
|
+
const answer = input.value.trim().slice(0, MAX_ANSWER_CHARS)
|
|
330
|
+
if (!answer) return
|
|
331
|
+
if (!preview) {
|
|
332
|
+
addToSet(storage('local'), ANSWERED_KEY, change.id)
|
|
333
|
+
opts.track(VOICE_EVENT, {
|
|
334
|
+
question: change.content.text,
|
|
335
|
+
text: answer,
|
|
336
|
+
action_plan_id: change.plan_id,
|
|
337
|
+
change_id: change.id,
|
|
338
|
+
path: location.pathname,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
card.replaceChildren(Object.assign(document.createElement('p'), { textContent: 'Obrigado pela resposta.' }))
|
|
342
|
+
setTimeout(remove, THANKS_MS)
|
|
343
|
+
})
|
|
344
|
+
card.append(head, input, send)
|
|
345
|
+
} else {
|
|
346
|
+
card.append(text, close)
|
|
347
|
+
}
|
|
348
|
+
if (preview) card.prepend(previewTag())
|
|
240
349
|
card.style.top = '-9999px'
|
|
241
350
|
card.style.left = '-9999px'
|
|
242
351
|
shadow.appendChild(card)
|
|
@@ -269,6 +378,11 @@ export function installProductChanges(opts: ProductChangesOptions): ProductChang
|
|
|
269
378
|
}
|
|
270
379
|
|
|
271
380
|
close.addEventListener('click', () => {
|
|
381
|
+
if (preview) {
|
|
382
|
+
dismissedPreview = true
|
|
383
|
+
remove()
|
|
384
|
+
return
|
|
385
|
+
}
|
|
272
386
|
addToSet(storage('local'), DISMISSED_KEY, change.id)
|
|
273
387
|
opts.track('$change_dismissed', { change_id: change.id, kind: change.kind })
|
|
274
388
|
remove()
|
|
@@ -280,21 +394,154 @@ export function installProductChanges(opts: ProductChangesOptions): ProductChang
|
|
|
280
394
|
return true
|
|
281
395
|
}
|
|
282
396
|
|
|
397
|
+
const doneOf = (change: ProductChange): Set<string> => progress.get(change.id) ?? new Set()
|
|
398
|
+
|
|
399
|
+
const allDone = (change: ProductChange): boolean => {
|
|
400
|
+
const done = doneOf(change)
|
|
401
|
+
return (change.content.items ?? []).every((i) => done.has(i.definition_name))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const fetchProgress = async (change: ProductChange): Promise<void> => {
|
|
405
|
+
const account = opts.getAccountKey()
|
|
406
|
+
if (!account) return
|
|
407
|
+
const last = progressAt.get(change.id) ?? 0
|
|
408
|
+
if (Date.now() - last < PROGRESS_REFRESH_MS) return
|
|
409
|
+
progressAt.set(change.id, Date.now())
|
|
410
|
+
try {
|
|
411
|
+
const res = await fetch(
|
|
412
|
+
`${opts.baseUrl}/runtime/checklist?change_id=${encodeURIComponent(change.id)}&account=${encodeURIComponent(account)}`,
|
|
413
|
+
{ headers: { Authorization: `Bearer ${opts.apiKey}` } },
|
|
414
|
+
)
|
|
415
|
+
if (!res.ok) return
|
|
416
|
+
const body = (await res.json()) as { done?: unknown }
|
|
417
|
+
const done = Array.isArray(body.done) ? body.done.filter((d): d is string => typeof d === 'string') : []
|
|
418
|
+
const set = doneOf(change)
|
|
419
|
+
for (const d of done) set.add(d)
|
|
420
|
+
progress.set(change.id, set)
|
|
421
|
+
lists.get(change.id)?.()
|
|
422
|
+
} catch (err) {
|
|
423
|
+
log(`checklist progress error: ${(err as Error).message}`)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const showChecklist = (change: ProductChange, preview: boolean): boolean => {
|
|
428
|
+
if (lists.has(change.id)) return true
|
|
429
|
+
if (!preview && readSet(storage('local'), COMPLETED_KEY).has(change.id)) return true
|
|
430
|
+
const shadow = ensureRoot()
|
|
431
|
+
if (!shadow) return false
|
|
432
|
+
const items = change.content.items ?? []
|
|
433
|
+
|
|
434
|
+
const card = document.createElement('div')
|
|
435
|
+
card.className = 'tip list'
|
|
436
|
+
card.setAttribute('role', 'region')
|
|
437
|
+
card.setAttribute('aria-label', change.content.text)
|
|
438
|
+
const head = document.createElement('div')
|
|
439
|
+
head.className = 'head'
|
|
440
|
+
const title = document.createElement('p')
|
|
441
|
+
const strong = document.createElement('strong')
|
|
442
|
+
strong.textContent = change.content.text
|
|
443
|
+
const count = document.createElement('span')
|
|
444
|
+
count.className = 'count'
|
|
445
|
+
title.append(strong, count)
|
|
446
|
+
const fold = document.createElement('button')
|
|
447
|
+
fold.type = 'button'
|
|
448
|
+
head.append(title, fold)
|
|
449
|
+
const list = document.createElement('ul')
|
|
450
|
+
card.append(head, list)
|
|
451
|
+
if (preview) card.prepend(previewTag())
|
|
452
|
+
card.style.position = 'fixed'
|
|
453
|
+
card.style.right = '16px'
|
|
454
|
+
card.style.bottom = '16px'
|
|
455
|
+
shadow.appendChild(card)
|
|
456
|
+
|
|
457
|
+
const remove = (): void => {
|
|
458
|
+
card.remove()
|
|
459
|
+
lists.delete(change.id)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const render = (): void => {
|
|
463
|
+
const done = doneOf(change)
|
|
464
|
+
const n = items.filter((i) => done.has(i.definition_name)).length
|
|
465
|
+
count.textContent = `${n} de ${items.length}`
|
|
466
|
+
const folded = readSet(storage('local'), FOLDED_KEY).has(change.id)
|
|
467
|
+
card.classList.toggle('folded', folded)
|
|
468
|
+
fold.textContent = folded ? '+' : '–'
|
|
469
|
+
fold.setAttribute('aria-label', folded ? 'Abrir' : 'Recolher')
|
|
470
|
+
list.replaceChildren(
|
|
471
|
+
...items.map((item) => {
|
|
472
|
+
const li = document.createElement('li')
|
|
473
|
+
const isDone = done.has(item.definition_name)
|
|
474
|
+
if (isDone) li.className = 'done'
|
|
475
|
+
const mark = document.createElement('span')
|
|
476
|
+
mark.className = 'mark'
|
|
477
|
+
mark.setAttribute('aria-hidden', 'true')
|
|
478
|
+
mark.textContent = isDone ? '✓' : ''
|
|
479
|
+
const label = document.createElement('span')
|
|
480
|
+
label.textContent = item.label
|
|
481
|
+
li.append(mark, label)
|
|
482
|
+
return li
|
|
483
|
+
}),
|
|
484
|
+
)
|
|
485
|
+
if (!preview && n === items.length && items.length > 0) {
|
|
486
|
+
addToSet(storage('local'), COMPLETED_KEY, change.id)
|
|
487
|
+
opts.track('$checklist_completed', { change_id: change.id })
|
|
488
|
+
remove()
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
fold.addEventListener('click', () => {
|
|
493
|
+
const store = storage('local')
|
|
494
|
+
const set = readSet(store, FOLDED_KEY)
|
|
495
|
+
if (set.has(change.id)) set.delete(change.id)
|
|
496
|
+
else set.add(change.id)
|
|
497
|
+
try {
|
|
498
|
+
store?.setItem(FOLDED_KEY, JSON.stringify([...set].slice(-200)))
|
|
499
|
+
} catch {
|
|
500
|
+
/* storage indisponível */
|
|
501
|
+
}
|
|
502
|
+
render()
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
lists.set(change.id, render)
|
|
506
|
+
render()
|
|
507
|
+
void fetchProgress(change)
|
|
508
|
+
return true
|
|
509
|
+
}
|
|
510
|
+
|
|
283
511
|
const scan = (): void => {
|
|
284
512
|
timer = null
|
|
285
|
-
const active = changesForPage(changes, location.pathname, opts.getAccountKey())
|
|
513
|
+
const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId)
|
|
286
514
|
const activeIds = new Set(active.map((a) => a.change.id))
|
|
287
515
|
for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id)
|
|
288
516
|
for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove()
|
|
517
|
+
for (const [id, render] of [...lists]) {
|
|
518
|
+
if (!activeIds.has(id)) {
|
|
519
|
+
lists.delete(id)
|
|
520
|
+
render()
|
|
521
|
+
}
|
|
522
|
+
}
|
|
289
523
|
|
|
290
|
-
for (const { change, arm } of active) {
|
|
524
|
+
for (const { change, arm, preview } of active) {
|
|
291
525
|
const el = findAnchor(change.anchor)
|
|
292
|
-
if (!el && change.anchor.kind !== 'page')
|
|
293
|
-
|
|
526
|
+
if (!el && change.anchor.kind !== 'page') {
|
|
527
|
+
if (preview) log(`preview: âncora não encontrada em ${location.pathname}`)
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
if (preview) {
|
|
531
|
+
if (change.kind === 'checklist') showChecklist(change, true)
|
|
532
|
+
else if (change.kind === 'copy') {
|
|
533
|
+
if (el) applyCopy(el, change)
|
|
534
|
+
} else if (!dismissedPreview) showTip(el, change, true)
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (change.kind === 'checklist') {
|
|
538
|
+
if (arm === 'holdout' || allDone(change) || showChecklist(change, false)) expose(change, arm)
|
|
539
|
+
if (arm === 'change') void fetchProgress(change)
|
|
540
|
+
} else if (change.kind === 'copy') {
|
|
294
541
|
if (!el) continue
|
|
295
542
|
const shown = arm === 'change' ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null
|
|
296
543
|
if (shown) expose(change, arm)
|
|
297
|
-
} else if (arm === 'holdout' || showTip(el, change)) {
|
|
544
|
+
} else if (arm === 'holdout' || showTip(el, change, false)) {
|
|
298
545
|
expose(change, arm)
|
|
299
546
|
}
|
|
300
547
|
}
|
|
@@ -306,8 +553,14 @@ export function installProductChanges(opts: ProductChangesOptions): ProductChang
|
|
|
306
553
|
|
|
307
554
|
const refresh = async (): Promise<void> => {
|
|
308
555
|
lastFetch = Date.now()
|
|
556
|
+
const account = opts.getAccountKey()
|
|
557
|
+
fetchedForKey = account
|
|
558
|
+
const params = new URLSearchParams()
|
|
559
|
+
if (account) params.set('account', account)
|
|
560
|
+
if (previewId) params.set('preview', previewId)
|
|
561
|
+
const qs = params.toString()
|
|
309
562
|
try {
|
|
310
|
-
const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
|
|
563
|
+
const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ''}`, {
|
|
311
564
|
headers: { Authorization: `Bearer ${opts.apiKey}` },
|
|
312
565
|
})
|
|
313
566
|
if (!res.ok) {
|
|
@@ -338,9 +591,28 @@ export function installProductChanges(opts: ProductChangesOptions): ProductChang
|
|
|
338
591
|
|
|
339
592
|
return {
|
|
340
593
|
onNavigate: () => {
|
|
341
|
-
|
|
594
|
+
const asked = resolvePreviewId(location.search, storage('session'))
|
|
595
|
+
const previewChanged = asked !== previewId
|
|
596
|
+
if (previewChanged) {
|
|
597
|
+
previewId = asked
|
|
598
|
+
dismissedPreview = false
|
|
599
|
+
for (const id of [...copies.keys()]) restoreCopy(id)
|
|
600
|
+
for (const remove of [...tips.values()]) remove()
|
|
601
|
+
}
|
|
602
|
+
if (previewChanged || Date.now() - lastFetch > NAV_REFRESH_MS || opts.getAccountKey() !== fetchedForKey) void refresh()
|
|
342
603
|
schedule()
|
|
343
604
|
},
|
|
605
|
+
onEvent: (name) => {
|
|
606
|
+
for (const change of changes) {
|
|
607
|
+
if (change.kind !== 'checklist') continue
|
|
608
|
+
const hit = (change.content.items ?? []).filter((i) => i.events.includes(name))
|
|
609
|
+
if (hit.length === 0) continue
|
|
610
|
+
const set = doneOf(change)
|
|
611
|
+
for (const i of hit) set.add(i.definition_name)
|
|
612
|
+
progress.set(change.id, set)
|
|
613
|
+
lists.get(change.id)?.()
|
|
614
|
+
}
|
|
615
|
+
},
|
|
344
616
|
refresh,
|
|
345
617
|
teardown: () => {
|
|
346
618
|
clearInterval(interval)
|
|
@@ -348,6 +620,7 @@ export function installProductChanges(opts: ProductChangesOptions): ProductChang
|
|
|
348
620
|
observer?.disconnect()
|
|
349
621
|
for (const id of [...copies.keys()]) restoreCopy(id)
|
|
350
622
|
for (const remove of [...tips.values()]) remove()
|
|
623
|
+
for (const id of [...lists.keys()]) lists.delete(id)
|
|
351
624
|
host?.remove()
|
|
352
625
|
host = null
|
|
353
626
|
root = null
|
package/src/core.test.ts
CHANGED
|
@@ -55,6 +55,21 @@ describe('HoldyCore', () => {
|
|
|
55
55
|
expect(holdy.pending).toBe(0)
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
it('release vai no context de todo evento, e só quando foi informada', async () => {
|
|
59
|
+
const { sent } = mockFetch()
|
|
60
|
+
const holdy = makeClient({ release: ' a1b2c3d ' })
|
|
61
|
+
holdy.track('invite_sent')
|
|
62
|
+
holdy.track('invite_sent', undefined, { context: { release: 'da-chamada' } })
|
|
63
|
+
await holdy.flush()
|
|
64
|
+
expect(sent[0].events[0].context).toMatchObject({ release: 'a1b2c3d' })
|
|
65
|
+
expect(sent[0].events[1].context).toMatchObject({ release: 'da-chamada' })
|
|
66
|
+
|
|
67
|
+
const bare = makeClient()
|
|
68
|
+
bare.track('invite_sent')
|
|
69
|
+
await bare.flush()
|
|
70
|
+
expect(sent[1].events[0].context).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
58
73
|
it('identify emite $identify e persiste user_id nos próximos events', async () => {
|
|
59
74
|
const { sent } = mockFetch()
|
|
60
75
|
const holdy = makeClient()
|
package/src/core.ts
CHANGED
|
@@ -27,6 +27,7 @@ export class HoldyCore {
|
|
|
27
27
|
private readonly flushAt: number
|
|
28
28
|
private readonly debug: boolean
|
|
29
29
|
private readonly source: 'sdk_web' | 'sdk_server'
|
|
30
|
+
private readonly release: string | null
|
|
30
31
|
|
|
31
32
|
private queue: CanonicalEvent[] = []
|
|
32
33
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
@@ -46,6 +47,7 @@ export class HoldyCore {
|
|
|
46
47
|
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '')
|
|
47
48
|
this.flushAt = Math.max(1, opts.flushAt ?? DEFAULT_FLUSH_AT)
|
|
48
49
|
this.debug = opts.debug ?? false
|
|
50
|
+
this.release = typeof opts.release === 'string' && opts.release.trim() ? opts.release.trim().slice(0, 64) : null
|
|
49
51
|
|
|
50
52
|
const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS
|
|
51
53
|
if (intervalMs > 0) {
|
|
@@ -71,6 +73,7 @@ export class HoldyCore {
|
|
|
71
73
|
// porque é o que a Data layer usa pra resolver a conta.
|
|
72
74
|
const context: Properties = {
|
|
73
75
|
...this.environmentContext(),
|
|
76
|
+
...(this.release ? { release: this.release } : {}),
|
|
74
77
|
...(overrides?.context ?? {}),
|
|
75
78
|
}
|
|
76
79
|
if (accountKey) context.account_external_id = accountKey
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { installFrictionCapture, type FrictionCapture, type FrictionSignal } from './friction'
|
|
4
|
+
|
|
5
|
+
let capture: FrictionCapture | null = null
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
capture?.teardown()
|
|
9
|
+
capture = null
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function errorWithStack(message: string, stack: string): Error {
|
|
13
|
+
const err = new Error(message)
|
|
14
|
+
err.stack = stack
|
|
15
|
+
return err
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('captura de erro de JS no navegador', () => {
|
|
19
|
+
it('erro de página sai com mensagem, linha, coluna e a pilha limpa', () => {
|
|
20
|
+
const signals: FrictionSignal[] = []
|
|
21
|
+
capture = installFrictionCapture({ onFriction: (s) => signals.push(s) })
|
|
22
|
+
const error = errorWithStack(
|
|
23
|
+
'dueDate indefinido',
|
|
24
|
+
'Error: dueDate indefinido\n at salvar (https://app.acme.com/chunks/page.js?v=9:3:41)\n at onClick (https://app.acme.com/chunks/main.js:8:2)',
|
|
25
|
+
)
|
|
26
|
+
window.dispatchEvent(
|
|
27
|
+
new ErrorEvent('error', { message: 'dueDate indefinido', filename: 'https://app.acme.com/chunks/page.js?v=9', lineno: 3, colno: 41, error }),
|
|
28
|
+
)
|
|
29
|
+
expect(signals).toHaveLength(1)
|
|
30
|
+
expect(signals[0].kind).toBe('js_error')
|
|
31
|
+
expect(signals[0].properties).toMatchObject({
|
|
32
|
+
message: 'dueDate indefinido',
|
|
33
|
+
source: 'https://app.acme.com/chunks/page.js',
|
|
34
|
+
line: 3,
|
|
35
|
+
column: 41,
|
|
36
|
+
stack: 'at salvar (https://app.acme.com/chunks/page.js:3:41)\nat onClick (https://app.acme.com/chunks/main.js:8:2)',
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('promise rejeitada leva a pilha do motivo, e erro sem pilha sai sem o campo', () => {
|
|
41
|
+
const signals: FrictionSignal[] = []
|
|
42
|
+
capture = installFrictionCapture({ onFriction: (s) => signals.push(s) })
|
|
43
|
+
const rejection = new Event('unhandledrejection') as Event & { reason?: unknown }
|
|
44
|
+
rejection.reason = errorWithStack('falhou', 'Error: falhou\n at carregar (https://app.acme.com/chunks/a.js:1:7)')
|
|
45
|
+
window.dispatchEvent(rejection)
|
|
46
|
+
window.dispatchEvent(new ErrorEvent('error', { message: 'Script error.' }))
|
|
47
|
+
expect(signals[0].properties).toMatchObject({
|
|
48
|
+
message: 'falhou',
|
|
49
|
+
unhandled_rejection: true,
|
|
50
|
+
stack: 'at carregar (https://app.acme.com/chunks/a.js:1:7)',
|
|
51
|
+
})
|
|
52
|
+
expect('stack' in signals[1].properties).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/friction.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
describeElement,
|
|
7
7
|
stripRequestUrl,
|
|
8
8
|
errorMessage,
|
|
9
|
+
sanitizeStack,
|
|
9
10
|
installFrictionCapture,
|
|
10
11
|
classifyRequest,
|
|
11
12
|
documentStatus,
|
|
@@ -181,6 +182,13 @@ describe('isDeadClickTarget', () => {
|
|
|
181
182
|
const icon = el('svg', {}, { closest: () => button })
|
|
182
183
|
expect(isDeadClickTarget(icon)).toBe(true)
|
|
183
184
|
})
|
|
185
|
+
|
|
186
|
+
it('alvo sem tagName não derruba o ouvinte, como no navegador do Facebook em 2026-10-08', () => {
|
|
187
|
+
const odd: ElementLike = { getAttribute: () => null }
|
|
188
|
+
expect(() => isDeadClickTarget(odd)).not.toThrow()
|
|
189
|
+
expect(isDeadClickTarget(odd)).toBe(false)
|
|
190
|
+
expect(() => describeElement(odd)).not.toThrow()
|
|
191
|
+
})
|
|
184
192
|
})
|
|
185
193
|
|
|
186
194
|
describe('describeElement', () => {
|
|
@@ -221,3 +229,38 @@ describe('installFrictionCapture fora do browser', () => {
|
|
|
221
229
|
expect(() => capture.teardown()).not.toThrow()
|
|
222
230
|
})
|
|
223
231
|
})
|
|
232
|
+
|
|
233
|
+
describe('sanitizeStack', () => {
|
|
234
|
+
it('guarda só os frames, sem a linha da mensagem e sem query na URL', () => {
|
|
235
|
+
const stack = [
|
|
236
|
+
"TypeError: Cannot read properties of undefined (reading 'dueDate') de ana@acme.com",
|
|
237
|
+
' at ReminderForm (https://app.acme.com/_next/static/chunks/app/lembretes/page-1a2b.js?token=abc&v=2:1:4821)',
|
|
238
|
+
' at onSubmit (https://app.acme.com/_next/static/chunks/main.js#frag:12:99)',
|
|
239
|
+
' at async Promise.all (index 0)',
|
|
240
|
+
].join('\n')
|
|
241
|
+
expect(sanitizeStack(stack)).toBe(
|
|
242
|
+
[
|
|
243
|
+
'at ReminderForm (https://app.acme.com/_next/static/chunks/app/lembretes/page-1a2b.js:1:4821)',
|
|
244
|
+
'at onSubmit (https://app.acme.com/_next/static/chunks/main.js:12:99)',
|
|
245
|
+
'at async Promise.all (index 0)',
|
|
246
|
+
].join('\n'),
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('lê o formato do Firefox e do Safari', () => {
|
|
251
|
+
const stack = 'salvar@https://app.acme.com/assets/app.js?v=3:10:5\n@https://app.acme.com/assets/app.js:22:1'
|
|
252
|
+
expect(sanitizeStack(stack)).toBe(
|
|
253
|
+
'salvar@https://app.acme.com/assets/app.js:10:5\n@https://app.acme.com/assets/app.js:22:1',
|
|
254
|
+
)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('corta em 8 frames de até 200 caracteres e devolve null quando não há frame', () => {
|
|
258
|
+
const frame = ` at f (https://app.acme.com/${'a'.repeat(300)}.js:1:1)`
|
|
259
|
+
const out = sanitizeStack(['Error: x', ...Array.from({ length: 20 }, () => frame)].join('\n')) ?? ''
|
|
260
|
+
expect(out.split('\n')).toHaveLength(8)
|
|
261
|
+
expect(out.split('\n').every((line) => line.length <= 200)).toBe(true)
|
|
262
|
+
expect(sanitizeStack('Error: só a mensagem')).toBeNull()
|
|
263
|
+
expect(sanitizeStack(undefined)).toBeNull()
|
|
264
|
+
expect(sanitizeStack('')).toBeNull()
|
|
265
|
+
})
|
|
266
|
+
})
|