@holdyapp/sdk 1.5.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.
Files changed (49) hide show
  1. package/README.md +9 -1
  2. package/dist/changes.d.ts +16 -2
  3. package/dist/changes.d.ts.map +1 -1
  4. package/dist/core.d.ts +1 -0
  5. package/dist/core.d.ts.map +1 -1
  6. package/dist/friction.d.ts +2 -1
  7. package/dist/friction.d.ts.map +1 -1
  8. package/dist/holdy.js +25 -4
  9. package/dist/holdy.js.map +4 -4
  10. package/dist/index.cjs +540 -33
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.mjs +540 -33
  13. package/dist/index.mjs.map +4 -4
  14. package/dist/server.cjs +3 -0
  15. package/dist/server.cjs.map +2 -2
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +2 -2
  18. package/dist/skeleton.d.ts +52 -0
  19. package/dist/skeleton.d.ts.map +1 -0
  20. package/dist/theme.d.ts +43 -0
  21. package/dist/theme.d.ts.map +1 -0
  22. package/dist/tip-css.d.ts +9 -0
  23. package/dist/tip-css.d.ts.map +1 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/web-context.d.ts +1 -0
  27. package/dist/web-context.d.ts.map +1 -1
  28. package/dist/web.d.ts +10 -1
  29. package/dist/web.d.ts.map +1 -1
  30. package/dist/web.mjs +540 -33
  31. package/dist/web.mjs.map +4 -4
  32. package/package.json +1 -1
  33. package/src/changes.dom.test.ts +221 -0
  34. package/src/changes.test.ts +38 -0
  35. package/src/changes.ts +299 -26
  36. package/src/core.test.ts +15 -0
  37. package/src/core.ts +3 -0
  38. package/src/friction.dom.test.ts +54 -0
  39. package/src/friction.test.ts +43 -0
  40. package/src/friction.ts +44 -7
  41. package/src/skeleton.test.ts +60 -0
  42. package/src/skeleton.ts +182 -0
  43. package/src/theme.test.ts +118 -0
  44. package/src/theme.ts +120 -0
  45. package/src/tip-css.ts +32 -0
  46. package/src/types.ts +6 -0
  47. package/src/web-context.test.ts +18 -0
  48. package/src/web-context.ts +8 -0
  49. package/src/web.ts +28 -3
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' || c.kind === 'guidance') &&
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
- ): Array<{ change: ProductChange; arm: ChangeArm }> {
78
- if (!accountKey) return []
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
- .map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }))
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
- const showTip = (anchor: Element | null, change: ProductChange): boolean => {
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
- card.append(text, close)
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') continue
293
- if (change.kind === 'copy') {
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
- if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh()
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
+ })
@@ -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
+ })