@holdyapp/sdk 0.2.1 → 1.0.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 (75) hide show
  1. package/README.md +20 -147
  2. package/dist/auto-capture.d.ts +12 -16
  3. package/dist/auto-capture.d.ts.map +1 -0
  4. package/dist/browser.d.ts +23 -0
  5. package/dist/browser.d.ts.map +1 -0
  6. package/dist/core.d.ts +42 -0
  7. package/dist/core.d.ts.map +1 -0
  8. package/dist/holdy.js +2 -0
  9. package/dist/holdy.js.map +7 -0
  10. package/dist/index.cjs.js +422 -0
  11. package/dist/index.cjs.js.map +7 -0
  12. package/dist/index.d.ts +5 -46
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.mjs +399 -0
  15. package/dist/index.mjs.map +7 -0
  16. package/dist/server.cjs.js +171 -0
  17. package/dist/server.cjs.js.map +7 -0
  18. package/dist/server.d.ts +15 -0
  19. package/dist/server.d.ts.map +1 -0
  20. package/dist/server.mjs +148 -0
  21. package/dist/server.mjs.map +7 -0
  22. package/dist/types.d.ts +41 -109
  23. package/dist/types.d.ts.map +1 -0
  24. package/dist/web-context.d.ts +51 -0
  25. package/dist/web-context.d.ts.map +1 -0
  26. package/dist/web.d.ts +41 -0
  27. package/dist/web.d.ts.map +1 -0
  28. package/dist/web.mjs +389 -0
  29. package/dist/web.mjs.map +7 -0
  30. package/package.json +24 -20
  31. package/src/auto-capture.ts +51 -78
  32. package/src/browser.ts +55 -0
  33. package/src/core.test.ts +147 -0
  34. package/src/core.ts +172 -0
  35. package/src/index.ts +4 -427
  36. package/src/server.ts +19 -0
  37. package/src/types.ts +38 -162
  38. package/src/web-context.test.ts +71 -0
  39. package/src/web-context.ts +227 -0
  40. package/src/web.ts +108 -0
  41. package/LICENSE +0 -21
  42. package/dist/anchor.d.ts +0 -8
  43. package/dist/holdy.cjs.js +0 -255
  44. package/dist/holdy.cjs.js.map +0 -7
  45. package/dist/holdy.esm.js +0 -255
  46. package/dist/holdy.esm.js.map +0 -7
  47. package/dist/holdy.iife.js +0 -255
  48. package/dist/holdy.iife.js.map +0 -7
  49. package/dist/outcome.d.ts +0 -19
  50. package/dist/queue.d.ts +0 -54
  51. package/dist/render/kinds/banner.d.ts +0 -3
  52. package/dist/render/kinds/checklist.d.ts +0 -19
  53. package/dist/render/kinds/cta.d.ts +0 -8
  54. package/dist/render/kinds/modal.d.ts +0 -16
  55. package/dist/render/kinds/shared.d.ts +0 -16
  56. package/dist/render/kinds/spotlight.d.ts +0 -17
  57. package/dist/render/kinds/tooltip.d.ts +0 -3
  58. package/dist/render/root.d.ts +0 -3
  59. package/dist/render/styles.d.ts +0 -5
  60. package/dist/store.d.ts +0 -36
  61. package/dist/transport.d.ts +0 -59
  62. package/src/anchor.ts +0 -72
  63. package/src/outcome.ts +0 -51
  64. package/src/queue.ts +0 -296
  65. package/src/render/kinds/banner.ts +0 -52
  66. package/src/render/kinds/checklist.ts +0 -175
  67. package/src/render/kinds/cta.ts +0 -53
  68. package/src/render/kinds/modal.ts +0 -129
  69. package/src/render/kinds/shared.ts +0 -67
  70. package/src/render/kinds/spotlight.ts +0 -176
  71. package/src/render/kinds/tooltip.ts +0 -82
  72. package/src/render/root.ts +0 -69
  73. package/src/render/styles.ts +0 -280
  74. package/src/store.ts +0 -106
  75. package/src/transport.ts +0 -182
package/src/queue.ts DELETED
@@ -1,296 +0,0 @@
1
- /**
2
- * Event queue · IndexedDB-backed retry com exponential backoff.
3
- *
4
- * Por que existe: SDK fire-and-forget perde eventos quando rede falha
5
- * (offline · 429 · 500 transitório · pagehide mid-flight). Sem fila, drop
6
- * silencioso · learning loop pega só os 80-90% que chegam.
7
- *
8
- * Comportamento:
9
- * · enqueue(events) tenta enviar imediatamente. Se falha (ou já tinha fila),
10
- * grava em IndexedDB com nextRetryAt = agora + 1s.
11
- * · flusher background polla a cada 5s, dequeue ordenado por nextRetryAt,
12
- * manda batch. Sucesso → delete da fila. Falha → backoff próximo.
13
- * · Backoff: 1s → 2s → 4s → 8s → 16s. Após 5 tentativas, drop com log.
14
- * · Cap 1000 itens (rough proxy 50KB cada · 50MB max). Quando cheio, drop
15
- * o mais antigo (FIFO).
16
- * · pagehide: tenta drain síncrono final via keepalive fetch (browser
17
- * entrega depois mesmo se aba fechou).
18
- *
19
- * Fallback: ambientes sem IndexedDB (SSR · Privacy mode em Safari rígido) caem
20
- * pra fila em memória — perde tudo no reload, mas SDK ainda funciona.
21
- */
22
-
23
- const DB_NAME = 'holdy_sdk_v1'
24
- const STORE_NAME = 'event_queue'
25
- const MAX_QUEUE_SIZE = 1000
26
- const MAX_ATTEMPTS = 5
27
- const FLUSH_INTERVAL_MS = 5000
28
- const BACKOFF_BASE_MS = 1000
29
-
30
- export interface QueuedBatch {
31
- /** Auto-increment key. */
32
- id?: number
33
- /** Eventos serializados (já com idempotency_key gerado). */
34
- events: unknown[]
35
- attempts: number
36
- next_retry_at: number
37
- created_at: number
38
- }
39
-
40
- export interface SendFn {
41
- /** Retorna true em sucesso, false em falha (caller decide retry). */
42
- (body: { events: unknown[] }): Promise<boolean>
43
- }
44
-
45
- interface QueueDriver {
46
- add(batch: Omit<QueuedBatch, 'id'>): Promise<void>
47
- /** Retorna itens elegíveis pra retry (next_retry_at <= now), ordenados. */
48
- pickDue(now: number, limit: number): Promise<QueuedBatch[]>
49
- update(item: QueuedBatch): Promise<void>
50
- remove(id: number): Promise<void>
51
- count(): Promise<number>
52
- dropOldest(n: number): Promise<void>
53
- /** Pra pagehide drain. */
54
- all(): Promise<QueuedBatch[]>
55
- }
56
-
57
- /* ─────────────────────────────────────────────────────────────────────────
58
- * IndexedDB driver
59
- * ──────────────────────────────────────────────────────────────────────── */
60
-
61
- function isIdbAvailable(): boolean {
62
- return typeof indexedDB !== 'undefined'
63
- }
64
-
65
- let dbPromise: Promise<IDBDatabase> | null = null
66
-
67
- function openDb(): Promise<IDBDatabase> {
68
- if (dbPromise) return dbPromise
69
- dbPromise = new Promise((resolve, reject) => {
70
- const req = indexedDB.open(DB_NAME, 1)
71
- req.onupgradeneeded = () => {
72
- const db = req.result
73
- if (!db.objectStoreNames.contains(STORE_NAME)) {
74
- db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true })
75
- }
76
- }
77
- req.onsuccess = () => resolve(req.result)
78
- req.onerror = () => reject(req.error)
79
- })
80
- return dbPromise
81
- }
82
-
83
- function withStore<T>(
84
- mode: IDBTransactionMode,
85
- fn: (store: IDBObjectStore) => IDBRequest | Promise<T>,
86
- ): Promise<T> {
87
- return openDb().then(
88
- (db) =>
89
- new Promise<T>((resolve, reject) => {
90
- const tx = db.transaction(STORE_NAME, mode)
91
- const store = tx.objectStore(STORE_NAME)
92
- let result: T | undefined
93
- try {
94
- const out = fn(store)
95
- if (out instanceof IDBRequest) {
96
- out.onsuccess = () => {
97
- result = out.result as T
98
- }
99
- out.onerror = () => reject(out.error)
100
- } else {
101
- void out.then((v) => (result = v))
102
- }
103
- } catch (e) {
104
- reject(e)
105
- return
106
- }
107
- tx.oncomplete = () => resolve(result as T)
108
- tx.onerror = () => reject(tx.error)
109
- tx.onabort = () => reject(tx.error)
110
- }),
111
- )
112
- }
113
-
114
- const idbDriver: QueueDriver = {
115
- async add(batch) {
116
- await withStore('readwrite', (store) => store.add(batch))
117
- },
118
- async pickDue(now, limit) {
119
- const all = await withStore<QueuedBatch[]>('readonly', (store) => store.getAll())
120
- return (all ?? [])
121
- .filter((b) => b.next_retry_at <= now)
122
- .sort((a, b) => a.next_retry_at - b.next_retry_at)
123
- .slice(0, limit)
124
- },
125
- async update(item) {
126
- await withStore('readwrite', (store) => store.put(item))
127
- },
128
- async remove(id) {
129
- await withStore('readwrite', (store) => store.delete(id))
130
- },
131
- async count() {
132
- return withStore<number>('readonly', (store) => store.count())
133
- },
134
- async dropOldest(n) {
135
- const all = await withStore<QueuedBatch[]>('readonly', (store) => store.getAll())
136
- const sorted = (all ?? []).sort((a, b) => a.created_at - b.created_at).slice(0, n)
137
- for (const item of sorted) {
138
- if (item.id !== undefined) {
139
- await withStore('readwrite', (store) => store.delete(item.id as number))
140
- }
141
- }
142
- },
143
- async all() {
144
- return (await withStore<QueuedBatch[]>('readonly', (store) => store.getAll())) ?? []
145
- },
146
- }
147
-
148
- /* ─────────────────────────────────────────────────────────────────────────
149
- * In-memory fallback driver
150
- * ──────────────────────────────────────────────────────────────────────── */
151
-
152
- class MemoryDriver implements QueueDriver {
153
- private items: QueuedBatch[] = []
154
- private nextId = 1
155
-
156
- async add(batch: Omit<QueuedBatch, 'id'>): Promise<void> {
157
- this.items.push({ ...batch, id: this.nextId++ })
158
- }
159
- async pickDue(now: number, limit: number): Promise<QueuedBatch[]> {
160
- return this.items
161
- .filter((b) => b.next_retry_at <= now)
162
- .sort((a, b) => a.next_retry_at - b.next_retry_at)
163
- .slice(0, limit)
164
- }
165
- async update(item: QueuedBatch): Promise<void> {
166
- const idx = this.items.findIndex((i) => i.id === item.id)
167
- if (idx >= 0) this.items[idx] = item
168
- }
169
- async remove(id: number): Promise<void> {
170
- this.items = this.items.filter((i) => i.id !== id)
171
- }
172
- async count(): Promise<number> {
173
- return this.items.length
174
- }
175
- async dropOldest(n: number): Promise<void> {
176
- this.items.sort((a, b) => a.created_at - b.created_at)
177
- this.items.splice(0, n)
178
- }
179
- async all(): Promise<QueuedBatch[]> {
180
- return [...this.items]
181
- }
182
- }
183
-
184
- /* ─────────────────────────────────────────────────────────────────────────
185
- * EventQueue · facade
186
- * ──────────────────────────────────────────────────────────────────────── */
187
-
188
- export class EventQueue {
189
- private readonly driver: QueueDriver
190
- private readonly send: SendFn
191
- private flushTimer: ReturnType<typeof setInterval> | null = null
192
- private flushing = false
193
- private readonly log: (...args: unknown[]) => void
194
-
195
- constructor(send: SendFn, log: (...args: unknown[]) => void = () => undefined) {
196
- this.driver = isIdbAvailable() ? idbDriver : new MemoryDriver()
197
- this.send = send
198
- this.log = log
199
- this.startFlusher()
200
- this.bindPagehide()
201
- }
202
-
203
- /**
204
- * Tenta enviar imediato; se falhar OU já houver fila pendente (ordem),
205
- * adiciona com next_retry_at = agora + 1s.
206
- */
207
- async enqueue(events: unknown[]): Promise<void> {
208
- if (events.length === 0) return
209
-
210
- // Se já há fila, vai pro final pra preservar ordem temporal.
211
- const pending = await this.driver.count().catch(() => 0)
212
- if (pending === 0) {
213
- const ok = await this.send({ events }).catch(() => false)
214
- if (ok) return
215
- }
216
-
217
- // Cap · drop oldest se cheia
218
- if (pending >= MAX_QUEUE_SIZE) {
219
- const overflow = pending - MAX_QUEUE_SIZE + 1
220
- await this.driver.dropOldest(overflow).catch(() => undefined)
221
- this.log('queue full · dropped', overflow, 'oldest batches')
222
- }
223
-
224
- const now = Date.now()
225
- await this.driver
226
- .add({
227
- events,
228
- attempts: 0,
229
- next_retry_at: now + BACKOFF_BASE_MS,
230
- created_at: now,
231
- })
232
- .catch((e) => this.log('queue add failed', e))
233
- }
234
-
235
- private startFlusher(): void {
236
- if (this.flushTimer) return
237
- if (typeof setInterval === 'undefined') return
238
- this.flushTimer = setInterval(() => {
239
- void this.flushOnce()
240
- }, FLUSH_INTERVAL_MS)
241
- }
242
-
243
- private async flushOnce(): Promise<void> {
244
- if (this.flushing) return
245
- this.flushing = true
246
- try {
247
- const now = Date.now()
248
- const due = await this.driver.pickDue(now, 10).catch(() => [])
249
- for (const item of due) {
250
- const ok = await this.send({ events: item.events }).catch(() => false)
251
- if (ok && item.id !== undefined) {
252
- await this.driver.remove(item.id).catch(() => undefined)
253
- continue
254
- }
255
- const attempts = item.attempts + 1
256
- if (attempts >= MAX_ATTEMPTS) {
257
- if (item.id !== undefined) await this.driver.remove(item.id).catch(() => undefined)
258
- this.log('dropped batch after max attempts', { events: item.events.length })
259
- continue
260
- }
261
- const backoff = BACKOFF_BASE_MS * Math.pow(2, attempts)
262
- await this.driver
263
- .update({ ...item, attempts, next_retry_at: Date.now() + backoff })
264
- .catch(() => undefined)
265
- }
266
- } finally {
267
- this.flushing = false
268
- }
269
- }
270
-
271
- private bindPagehide(): void {
272
- if (typeof window === 'undefined') return
273
- const drain = (): void => {
274
- // Pagehide é sincrônico · não podemos await IDB. Em vez disso, dispara
275
- // 1 fetch keepalive por batch pendente. Browser entrega após close.
276
- void this.driver
277
- .all()
278
- .then((items) => {
279
- for (const item of items) {
280
- void this.send({ events: item.events }).catch(() => undefined)
281
- }
282
- })
283
- .catch(() => undefined)
284
- }
285
- window.addEventListener('pagehide', drain)
286
- window.addEventListener('beforeunload', drain)
287
- }
288
-
289
- /** Pra testes. */
290
- destroy(): void {
291
- if (this.flushTimer) {
292
- clearInterval(this.flushTimer)
293
- this.flushTimer = null
294
- }
295
- }
296
- }
@@ -1,52 +0,0 @@
1
- import type { InterventionPayload } from '../../types'
2
- import {
3
- buildActionsRow,
4
- buildCloseButton,
5
- buildCtaButton,
6
- buildTextNode,
7
- type RenderControl,
8
- type RenderHandlers,
9
- } from './shared'
10
-
11
- export function renderBanner(
12
- payload: InterventionPayload,
13
- handlers: RenderHandlers,
14
- ): RenderControl {
15
- const card = document.createElement('div')
16
- card.className = 'holdy-card holdy-banner'
17
- card.setAttribute('role', 'status')
18
-
19
- const text = buildTextNode(payload.text)
20
- card.appendChild(text)
21
-
22
- const close = buildCloseButton(() => dismiss('dismissed'))
23
- const cta = buildCtaButton(payload, () => {
24
- handlers.onExecuted()
25
- cleanup()
26
- })
27
-
28
- // Banner mantém actions inline (não em row separada) pra economizar altura.
29
- const inlineActions = buildActionsRow(cta, close)
30
- if (inlineActions) {
31
- inlineActions.style.marginTop = '0'
32
- card.appendChild(inlineActions)
33
- } else {
34
- card.appendChild(close)
35
- }
36
-
37
- let dismissed = false
38
- const cleanup = (): void => {
39
- if (dismissed) return
40
- dismissed = true
41
- card.remove()
42
- }
43
-
44
- const dismiss = (outcome: 'dismissed' | 'no_show' | 'failed' = 'dismissed'): void => {
45
- if (dismissed) return
46
- if (outcome === 'dismissed') handlers.onDismissed()
47
- if (outcome === 'failed') handlers.onFailed()
48
- cleanup()
49
- }
50
-
51
- return { el: card, dismiss: (o) => dismiss(o === 'failed' || o === 'no_show' ? o : 'dismissed') }
52
- }
@@ -1,175 +0,0 @@
1
- import type { ChecklistItem, InterventionPayload } from '../../types'
2
- import {
3
- buildCloseButton,
4
- type RenderControl,
5
- type RenderHandlers,
6
- } from './shared'
7
-
8
- const STATE_KEY_PREFIX = 'holdy_checklist_'
9
-
10
- /**
11
- * Checklist · sticky bottom-right com lista de tarefas.
12
- *
13
- * Persistência: estado por decision_id em localStorage (chave
14
- * `holdy_checklist_{decision_id}`). Rehidratado em re-render.
15
- *
16
- * UX:
17
- * · cada item tem checkbox · clique marca/desmarca
18
- * · item com href abre em _self quando label clicado (após marcar)
19
- * · primeiro completed dispara onExecuted UMA vez (não a cada check)
20
- * · todos completed dispara onExecuted com metadata={completed:true}
21
- * · close button dispara onDismissed
22
- *
23
- * NÃO há outcome 'converted' aqui — fica pro caller (Holdy backend) detectar
24
- * via workflow.completed downstream.
25
- */
26
- export function renderChecklist(
27
- payload: InterventionPayload,
28
- decisionId: string,
29
- handlers: RenderHandlers,
30
- ): RenderControl {
31
- const items = payload.items ?? []
32
- if (items.length === 0) {
33
- // payload inválido · checklist sem items
34
- handlers.onFailed({ reason: 'empty_items' })
35
- const empty = document.createElement('div')
36
- return { el: empty, dismiss: () => undefined }
37
- }
38
-
39
- const state = readState(decisionId, items)
40
-
41
- const card = document.createElement('div')
42
- card.className = 'holdy-card holdy-checklist'
43
- card.setAttribute('role', 'region')
44
- card.setAttribute('aria-label', payload.title ?? 'Lista de tarefas')
45
-
46
- // Header com title + close
47
- const header = document.createElement('div')
48
- header.className = 'holdy-checklist-header'
49
-
50
- if (payload.title) {
51
- const h = document.createElement('h3')
52
- h.className = 'holdy-checklist-title'
53
- h.textContent = payload.title
54
- header.appendChild(h)
55
- }
56
-
57
- const close = buildCloseButton(() => dismiss('dismissed'))
58
- header.appendChild(close)
59
- card.appendChild(header)
60
-
61
- if (payload.text) {
62
- const text = document.createElement('p')
63
- text.className = 'holdy-card-text'
64
- text.textContent = payload.text
65
- card.appendChild(text)
66
- }
67
-
68
- // Lista
69
- const list = document.createElement('ul')
70
- list.className = 'holdy-checklist-list'
71
-
72
- let firedExecuted = false
73
- let firedCompleted = false
74
-
75
- const renderItems = (): void => {
76
- list.textContent = ''
77
- items.forEach((item) => {
78
- const li = document.createElement('li')
79
- li.className = state[item.id] ? 'holdy-checklist-item is-done' : 'holdy-checklist-item'
80
-
81
- const checkbox = document.createElement('input')
82
- checkbox.type = 'checkbox'
83
- checkbox.className = 'holdy-checklist-checkbox'
84
- checkbox.checked = !!state[item.id]
85
- checkbox.setAttribute('aria-label', `Marcar ${item.label}`)
86
- checkbox.addEventListener('change', () => toggleItem(item, checkbox.checked))
87
-
88
- const labelEl = item.href
89
- ? document.createElement('a')
90
- : document.createElement('span')
91
- labelEl.className = 'holdy-checklist-label'
92
- labelEl.textContent = item.label
93
- if (item.href) {
94
- ;(labelEl as HTMLAnchorElement).href = item.href
95
- ;(labelEl as HTMLAnchorElement).rel = 'noopener noreferrer'
96
- labelEl.addEventListener('click', () => toggleItem(item, true))
97
- }
98
-
99
- li.appendChild(checkbox)
100
- li.appendChild(labelEl)
101
- list.appendChild(li)
102
- })
103
- }
104
-
105
- const toggleItem = (item: ChecklistItem, completed: boolean): void => {
106
- state[item.id] = completed
107
- persistState(decisionId, state)
108
-
109
- if (completed && !firedExecuted) {
110
- firedExecuted = true
111
- handlers.onExecuted({ item_id: item.id })
112
- }
113
-
114
- const allDone = items.every((i) => state[i.id])
115
- if (allDone && !firedCompleted) {
116
- firedCompleted = true
117
- handlers.onExecuted({ completed: true })
118
- }
119
-
120
- renderItems()
121
- }
122
-
123
- renderItems()
124
- card.appendChild(list)
125
-
126
- let dismissed = false
127
- const cleanup = (): void => {
128
- if (dismissed) return
129
- dismissed = true
130
- card.remove()
131
- }
132
-
133
- const dismiss = (outcome: 'dismissed' | 'no_show' | 'failed' = 'dismissed'): void => {
134
- if (dismissed) return
135
- if (outcome === 'dismissed') handlers.onDismissed()
136
- if (outcome === 'failed') handlers.onFailed()
137
- cleanup()
138
- }
139
-
140
- return { el: card, dismiss: (o) => dismiss(o === 'failed' || o === 'no_show' ? o : 'dismissed') }
141
- }
142
-
143
- function readState(
144
- decisionId: string,
145
- items: ChecklistItem[],
146
- ): Record<string, boolean> {
147
- // Default: respeitar `completed` do payload (server pode pré-marcar items)
148
- const initial: Record<string, boolean> = {}
149
- items.forEach((i) => {
150
- initial[i.id] = !!i.completed
151
- })
152
-
153
- if (typeof localStorage === 'undefined') return initial
154
- try {
155
- const raw = localStorage.getItem(STATE_KEY_PREFIX + decisionId)
156
- if (!raw) return initial
157
- const parsed = JSON.parse(raw) as Record<string, boolean>
158
- // Merge · localStorage tem prioridade sobre server pre-marks
159
- items.forEach((i) => {
160
- if (typeof parsed[i.id] === 'boolean') initial[i.id] = parsed[i.id]
161
- })
162
- return initial
163
- } catch {
164
- return initial
165
- }
166
- }
167
-
168
- function persistState(decisionId: string, state: Record<string, boolean>): void {
169
- if (typeof localStorage === 'undefined') return
170
- try {
171
- localStorage.setItem(STATE_KEY_PREFIX + decisionId, JSON.stringify(state))
172
- } catch {
173
- // quota · silent
174
- }
175
- }
@@ -1,53 +0,0 @@
1
- import type { InterventionPayload } from '../../types'
2
- import {
3
- buildActionsRow,
4
- buildCloseButton,
5
- buildCtaButton,
6
- buildTextNode,
7
- type RenderControl,
8
- type RenderHandlers,
9
- } from './shared'
10
-
11
- /**
12
- * CTA sticky bottom-right · usado quando não há anchor específico mas a
13
- * intervention precisa de presença persistente (ex: convite pra rodar
14
- * onboarding workflow).
15
- */
16
- export function renderCta(
17
- payload: InterventionPayload,
18
- handlers: RenderHandlers,
19
- ): RenderControl {
20
- const card = document.createElement('div')
21
- card.className = 'holdy-card holdy-cta'
22
- card.setAttribute('role', 'dialog')
23
- card.setAttribute('aria-label', 'Sugestão Holdy')
24
-
25
- const text = buildTextNode(payload.text)
26
- card.appendChild(text)
27
-
28
- const close = buildCloseButton(() => dismiss('dismissed'))
29
- const cta = buildCtaButton(payload, () => {
30
- handlers.onExecuted()
31
- cleanup()
32
- })
33
-
34
- const actions = buildActionsRow(cta, close)
35
- if (actions) card.appendChild(actions)
36
- else card.appendChild(close)
37
-
38
- let dismissed = false
39
- const cleanup = (): void => {
40
- if (dismissed) return
41
- dismissed = true
42
- card.remove()
43
- }
44
-
45
- const dismiss = (outcome: 'dismissed' | 'no_show' | 'failed' = 'dismissed'): void => {
46
- if (dismissed) return
47
- if (outcome === 'dismissed') handlers.onDismissed()
48
- if (outcome === 'failed') handlers.onFailed()
49
- cleanup()
50
- }
51
-
52
- return { el: card, dismiss: (o) => dismiss(o === 'failed' || o === 'no_show' ? o : 'dismissed') }
53
- }
@@ -1,129 +0,0 @@
1
- import type { InterventionPayload } from '../../types'
2
- import {
3
- buildActionsRow,
4
- buildCloseButton,
5
- buildCtaButton,
6
- buildTextNode,
7
- type RenderControl,
8
- type RenderHandlers,
9
- } from './shared'
10
-
11
- /**
12
- * Modal · overlay centralizado com backdrop.
13
- *
14
- * UX:
15
- * · backdrop click dismissa (igual padrão Apple/Stripe)
16
- * · ESC dismissa
17
- * · focus trap entre primeiro/último focusable
18
- * · scroll body bloqueado enquanto aberto
19
- * · role=dialog + aria-modal pra acessibilidade
20
- *
21
- * Uso: intervention de alto peso (renewal aproximando, churn risk
22
- * crítico, expansion oferecida com 1 click). Limite global de 1.
23
- */
24
- export function renderModal(
25
- payload: InterventionPayload,
26
- handlers: RenderHandlers,
27
- ): RenderControl {
28
- const backdrop = document.createElement('div')
29
- backdrop.className = 'holdy-modal-backdrop'
30
-
31
- const card = document.createElement('div')
32
- card.className = 'holdy-card holdy-modal'
33
- card.setAttribute('role', 'dialog')
34
- card.setAttribute('aria-modal', 'true')
35
- card.tabIndex = -1
36
-
37
- if (payload.title) {
38
- const h = document.createElement('h2')
39
- h.className = 'holdy-card-title'
40
- h.textContent = payload.title
41
- card.appendChild(h)
42
- }
43
-
44
- const text = buildTextNode(payload.text)
45
- card.appendChild(text)
46
-
47
- const close = buildCloseButton(() => dismiss('dismissed'))
48
- const cta = buildCtaButton(payload, () => {
49
- handlers.onExecuted()
50
- cleanup()
51
- })
52
-
53
- const actions = buildActionsRow(cta, close)
54
- if (actions) card.appendChild(actions)
55
- else card.appendChild(close)
56
-
57
- backdrop.appendChild(card)
58
-
59
- // Body scroll lock
60
- const prevBodyOverflow = document.body.style.overflow
61
- document.body.style.overflow = 'hidden'
62
-
63
- // Backdrop click dismissa (mas só se for clique direto no backdrop, não no card)
64
- backdrop.addEventListener('click', (e) => {
65
- if (e.target === backdrop) dismiss('dismissed', { reason: 'backdrop_click' })
66
- })
67
-
68
- // Focus management
69
- const previouslyFocused = document.activeElement as HTMLElement | null
70
- const focusFirst = (): void => {
71
- requestAnimationFrame(() => {
72
- const focusable = card.querySelector<HTMLElement>('a, button, [tabindex]:not([tabindex="-1"])')
73
- ;(focusable ?? card).focus()
74
- })
75
- }
76
- focusFirst()
77
-
78
- const onKeydown = (e: KeyboardEvent): void => {
79
- if (e.key === 'Escape') {
80
- e.stopPropagation()
81
- dismiss('dismissed', { reason: 'esc' })
82
- return
83
- }
84
- if (e.key !== 'Tab') return
85
- // Focus trap simples · só dentro do card
86
- const focusables = Array.from(
87
- card.querySelectorAll<HTMLElement>('a, button, [tabindex]:not([tabindex="-1"])'),
88
- )
89
- if (focusables.length === 0) return
90
- const first = focusables[0]
91
- const last = focusables[focusables.length - 1]
92
- const active = document.activeElement
93
- if (e.shiftKey && active === first) {
94
- e.preventDefault()
95
- last.focus()
96
- } else if (!e.shiftKey && active === last) {
97
- e.preventDefault()
98
- first.focus()
99
- }
100
- }
101
- document.addEventListener('keydown', onKeydown, true)
102
-
103
- let dismissed = false
104
- const cleanup = (): void => {
105
- if (dismissed) return
106
- dismissed = true
107
- document.body.style.overflow = prevBodyOverflow
108
- document.removeEventListener('keydown', onKeydown, true)
109
- backdrop.remove()
110
- if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
111
- previouslyFocused.focus()
112
- }
113
- }
114
-
115
- const dismiss = (
116
- outcome: 'dismissed' | 'no_show' | 'failed' = 'dismissed',
117
- metadata?: Record<string, unknown>,
118
- ): void => {
119
- if (dismissed) return
120
- if (outcome === 'dismissed') handlers.onDismissed(metadata)
121
- if (outcome === 'failed') handlers.onFailed(metadata)
122
- cleanup()
123
- }
124
-
125
- return {
126
- el: backdrop,
127
- dismiss: (o) => dismiss(o === 'failed' || o === 'no_show' ? o : 'dismissed'),
128
- }
129
- }