@holdyapp/sdk 0.2.1 → 1.0.1
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 +20 -147
- package/dist/auto-capture.d.ts +12 -16
- package/dist/auto-capture.d.ts.map +1 -0
- package/dist/browser.d.ts +23 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/core.d.ts +42 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/holdy.js +2 -0
- package/dist/holdy.js.map +7 -0
- package/dist/index.cjs +430 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +5 -46
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +399 -0
- package/dist/index.mjs.map +7 -0
- package/dist/server.cjs +177 -0
- package/dist/server.cjs.map +7 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.mjs +148 -0
- package/dist/server.mjs.map +7 -0
- package/dist/types.d.ts +41 -109
- package/dist/types.d.ts.map +1 -0
- package/dist/web-context.d.ts +51 -0
- package/dist/web-context.d.ts.map +1 -0
- package/dist/web.d.ts +41 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.mjs +389 -0
- package/dist/web.mjs.map +7 -0
- package/package.json +24 -20
- package/src/auto-capture.ts +51 -78
- package/src/browser.ts +55 -0
- package/src/core.test.ts +147 -0
- package/src/core.ts +172 -0
- package/src/index.ts +4 -427
- package/src/server.ts +19 -0
- package/src/types.ts +38 -162
- package/src/web-context.test.ts +71 -0
- package/src/web-context.ts +227 -0
- package/src/web.ts +108 -0
- package/LICENSE +0 -21
- package/dist/anchor.d.ts +0 -8
- package/dist/holdy.cjs.js +0 -255
- package/dist/holdy.cjs.js.map +0 -7
- package/dist/holdy.esm.js +0 -255
- package/dist/holdy.esm.js.map +0 -7
- package/dist/holdy.iife.js +0 -255
- package/dist/holdy.iife.js.map +0 -7
- package/dist/outcome.d.ts +0 -19
- package/dist/queue.d.ts +0 -54
- package/dist/render/kinds/banner.d.ts +0 -3
- package/dist/render/kinds/checklist.d.ts +0 -19
- package/dist/render/kinds/cta.d.ts +0 -8
- package/dist/render/kinds/modal.d.ts +0 -16
- package/dist/render/kinds/shared.d.ts +0 -16
- package/dist/render/kinds/spotlight.d.ts +0 -17
- package/dist/render/kinds/tooltip.d.ts +0 -3
- package/dist/render/root.d.ts +0 -3
- package/dist/render/styles.d.ts +0 -5
- package/dist/store.d.ts +0 -36
- package/dist/transport.d.ts +0 -59
- package/src/anchor.ts +0 -72
- package/src/outcome.ts +0 -51
- package/src/queue.ts +0 -296
- package/src/render/kinds/banner.ts +0 -52
- package/src/render/kinds/checklist.ts +0 -175
- package/src/render/kinds/cta.ts +0 -53
- package/src/render/kinds/modal.ts +0 -129
- package/src/render/kinds/shared.ts +0 -67
- package/src/render/kinds/spotlight.ts +0 -176
- package/src/render/kinds/tooltip.ts +0 -82
- package/src/render/root.ts +0 -69
- package/src/render/styles.ts +0 -280
- package/src/store.ts +0 -106
- 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
|
-
}
|
package/src/render/kinds/cta.ts
DELETED
|
@@ -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
|
-
}
|