@holdyapp/sdk 0.2.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/dist/anchor.d.ts +8 -0
  4. package/dist/auto-capture.d.ts +21 -0
  5. package/dist/holdy.cjs.js +255 -0
  6. package/dist/holdy.cjs.js.map +7 -0
  7. package/dist/holdy.esm.js +255 -0
  8. package/dist/holdy.esm.js.map +7 -0
  9. package/dist/holdy.iife.js +255 -0
  10. package/dist/holdy.iife.js.map +7 -0
  11. package/dist/index.d.ts +46 -0
  12. package/dist/outcome.d.ts +19 -0
  13. package/dist/queue.d.ts +54 -0
  14. package/dist/render/kinds/banner.d.ts +3 -0
  15. package/dist/render/kinds/checklist.d.ts +19 -0
  16. package/dist/render/kinds/cta.d.ts +8 -0
  17. package/dist/render/kinds/modal.d.ts +16 -0
  18. package/dist/render/kinds/shared.d.ts +16 -0
  19. package/dist/render/kinds/spotlight.d.ts +17 -0
  20. package/dist/render/kinds/tooltip.d.ts +3 -0
  21. package/dist/render/root.d.ts +3 -0
  22. package/dist/render/styles.d.ts +5 -0
  23. package/dist/store.d.ts +36 -0
  24. package/dist/transport.d.ts +59 -0
  25. package/dist/types.d.ts +114 -0
  26. package/package.json +60 -0
  27. package/src/anchor.ts +72 -0
  28. package/src/auto-capture.ts +129 -0
  29. package/src/index.ts +427 -0
  30. package/src/outcome.ts +51 -0
  31. package/src/queue.ts +296 -0
  32. package/src/render/kinds/banner.ts +52 -0
  33. package/src/render/kinds/checklist.ts +175 -0
  34. package/src/render/kinds/cta.ts +53 -0
  35. package/src/render/kinds/modal.ts +129 -0
  36. package/src/render/kinds/shared.ts +67 -0
  37. package/src/render/kinds/spotlight.ts +176 -0
  38. package/src/render/kinds/tooltip.ts +82 -0
  39. package/src/render/root.ts +69 -0
  40. package/src/render/styles.ts +280 -0
  41. package/src/store.ts +106 -0
  42. package/src/transport.ts +182 -0
  43. package/src/types.ts +173 -0
package/src/index.ts ADDED
@@ -0,0 +1,427 @@
1
+ import type {
2
+ AccountIdentity,
3
+ CanonicalPrimitive,
4
+ DecisionListener,
5
+ DecisionResult,
6
+ HoldyConfig,
7
+ IdentifyOptions,
8
+ InterventionPayload,
9
+ OutcomeValue,
10
+ RuntimeConfig,
11
+ ThemeConfig,
12
+ TrackOptions,
13
+ UserIdentity,
14
+ } from './types'
15
+ import { Transport } from './transport'
16
+ import { OutcomeTracker } from './outcome'
17
+ import { InterventionStore } from './store'
18
+ import { resolveAnchor } from './anchor'
19
+ import { installAutoCapture } from './auto-capture'
20
+ import { appendToRoot, ensureRoot } from './render/root'
21
+ import { renderTooltip } from './render/kinds/tooltip'
22
+ import { renderBanner } from './render/kinds/banner'
23
+ import { renderCta } from './render/kinds/cta'
24
+ import { renderModal } from './render/kinds/modal'
25
+ import { renderChecklist } from './render/kinds/checklist'
26
+ import { renderSpotlight } from './render/kinds/spotlight'
27
+ import type { RenderControl, RenderHandlers } from './render/kinds/shared'
28
+
29
+ const RING_BUFFER_LIMIT = 20
30
+ const CONFIG_CACHE_KEY = '__holdy_runtime_config_v1'
31
+ const CONFIG_CACHE_TTL_MS = 60 * 60 * 1000
32
+ const EVAL_THROTTLE_MS = 1500
33
+
34
+ interface RingEntry {
35
+ primitive: CanonicalPrimitive
36
+ payload: Record<string, unknown>
37
+ occurred_at: string
38
+ }
39
+
40
+ class HoldySdk {
41
+ private config: HoldyConfig | null = null
42
+ private transport: Transport | null = null
43
+ private outcomeTracker: OutcomeTracker | null = null
44
+ private store: InterventionStore = new InterventionStore()
45
+ private runtime: RuntimeConfig | null = null
46
+ private activeWorkflow: string | null = null
47
+ private sentiment: 'positivo' | 'neutro' | 'negativo' | 'urgente' | null = null
48
+ private readonly ring: RingEntry[] = []
49
+ private listeners: DecisionListener[] = []
50
+ private lastEvalAt = 0
51
+ private initialized = false
52
+ private autoCaptureCleanup: (() => void) | null = null
53
+
54
+ async init(config: HoldyConfig): Promise<void> {
55
+ if (this.initialized) {
56
+ this.log('init called twice · ignoring')
57
+ return
58
+ }
59
+ if (!config.apiKey) {
60
+ throw new Error('[holdy] apiKey is required')
61
+ }
62
+ this.config = config
63
+ this.transport = new Transport(config)
64
+ this.outcomeTracker = new OutcomeTracker(this.transport)
65
+ this.initialized = true
66
+
67
+ const cached = this.readCachedConfig()
68
+ if (cached) this.runtime = cached
69
+
70
+ const fresh = await this.transport.fetchRuntimeConfig()
71
+ if (fresh) {
72
+ this.runtime = fresh
73
+ this.writeCachedConfig(fresh)
74
+ }
75
+
76
+ if (this.shouldRender()) {
77
+ ensureRoot(this.themeFromRuntime())
78
+ }
79
+
80
+ // Stage 28 G_auto · auto-capture de page views.
81
+ // Habilita só quando customer pediu explicitamente · default off pra
82
+ // back-compat. Ring buffer + transport recebem session.activity igual
83
+ // se viesse de track() manual · decision engine consome no contexto.
84
+ if (config.autoCapture) {
85
+ this.autoCaptureCleanup = installAutoCapture((path, title, referrer, kind) => {
86
+ void this.track('session.activity', {
87
+ kind,
88
+ path,
89
+ ...(title ? { title } : {}),
90
+ ...(referrer ? { referrer } : {}),
91
+ }, { skipEval: kind !== 'route_change' })
92
+ })
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Identifica conta + usuário corrente.
98
+ *
99
+ * - `accountId/userId/sessionId` hidratam config local pra evaluate/track.
100
+ * - `account/user` (metadata rica) disparam upsert best-effort em
101
+ * /v1/accounts/upsert. Backend COALESCE → nunca sobrescreve campos do
102
+ * wizard manual. Em sucesso, account_id Holdy retornado hidrata config
103
+ * local automaticamente (cliente não precisa saber UUID).
104
+ */
105
+ identify(opts: IdentifyOptions): void {
106
+ if (!this.config) return
107
+ if (opts.accountId !== undefined) this.config.accountId = opts.accountId
108
+ if (opts.userId !== undefined) this.config.userId = opts.userId
109
+ if (opts.sessionId !== undefined) this.config.sessionId = opts.sessionId
110
+
111
+ if (opts.account || opts.user) {
112
+ void this.upsertIdentityRich(opts.account, opts.user)
113
+ }
114
+ }
115
+
116
+ private async upsertIdentityRich(
117
+ account: AccountIdentity | undefined,
118
+ user: UserIdentity | undefined,
119
+ ): Promise<void> {
120
+ if (!this.transport || !account) return
121
+ const result = await this.transport.upsertAccount({ account, user })
122
+ if (!result || !this.config) return
123
+ if (!this.config.accountId) this.config.accountId = result.account_id
124
+ if (!this.config.userId && result.account_user_id) {
125
+ this.config.userId = result.account_user_id
126
+ }
127
+ }
128
+
129
+ setWorkflow(workflowKey: string | null | undefined): void {
130
+ this.activeWorkflow = workflowKey ?? null
131
+ }
132
+
133
+ setSentiment(sentiment: 'positivo' | 'neutro' | 'negativo' | 'urgente' | null): void {
134
+ this.sentiment = sentiment
135
+ }
136
+
137
+ async track(
138
+ primitive: CanonicalPrimitive,
139
+ payload: Record<string, unknown> = {},
140
+ opts: TrackOptions = {},
141
+ ): Promise<void> {
142
+ if (!this.config || !this.transport) {
143
+ this.log('track called before init')
144
+ return
145
+ }
146
+ const occurred_at = new Date().toISOString()
147
+ const entry: RingEntry = { primitive, payload, occurred_at }
148
+ this.pushRing(entry)
149
+
150
+ // workflow tracking auto-sync
151
+ if (primitive === 'workflow.started' && typeof payload.workflow === 'string') {
152
+ this.activeWorkflow = payload.workflow
153
+ }
154
+ if (
155
+ (primitive === 'workflow.completed' || primitive === 'workflow.abandoned') &&
156
+ typeof payload.workflow === 'string' &&
157
+ this.activeWorkflow === payload.workflow
158
+ ) {
159
+ this.activeWorkflow = null
160
+ }
161
+ if (opts.workflow !== undefined) this.activeWorkflow = opts.workflow
162
+
163
+ // ingest fire-and-forget
164
+ this.transport.ingestBatch([
165
+ {
166
+ primitive,
167
+ occurred_at,
168
+ account_id: this.config.accountId,
169
+ user_id: this.config.userId,
170
+ session_id: this.config.sessionId,
171
+ payload,
172
+ },
173
+ ])
174
+
175
+ if (opts.skipEval) return
176
+ if (!this.shouldTriggerEval(primitive)) return
177
+
178
+ await this.evaluateInternal()
179
+ }
180
+
181
+ async evaluate(): Promise<DecisionResult | null> {
182
+ return this.evaluateInternal(true)
183
+ }
184
+
185
+ onDecision(listener: DecisionListener): () => void {
186
+ this.listeners.push(listener)
187
+ return () => {
188
+ this.listeners = this.listeners.filter((l) => l !== listener)
189
+ }
190
+ }
191
+
192
+ reportOutcome(
193
+ decisionId: string,
194
+ outcome: OutcomeValue,
195
+ metadata?: Record<string, unknown>,
196
+ ): void {
197
+ this.outcomeTracker?.dispatch(decisionId, outcome, metadata)
198
+ }
199
+
200
+ destroy(): void {
201
+ this.transport?.destroy()
202
+ if (this.autoCaptureCleanup) {
203
+ try { this.autoCaptureCleanup() } catch { /* ignore */ }
204
+ this.autoCaptureCleanup = null
205
+ }
206
+ this.config = null
207
+ this.transport = null
208
+ this.outcomeTracker = null
209
+ this.runtime = null
210
+ this.activeWorkflow = null
211
+ this.listeners = []
212
+ this.ring.length = 0
213
+ this.initialized = false
214
+ }
215
+
216
+ /* ───────── internals ───────── */
217
+
218
+ private async evaluateInternal(force = false): Promise<DecisionResult | null> {
219
+ if (!this.transport || !this.config?.accountId) {
220
+ this.log('evaluate skipped · no accountId set · call identify first')
221
+ return null
222
+ }
223
+ const now = Date.now()
224
+ if (!force && now - this.lastEvalAt < EVAL_THROTTLE_MS) {
225
+ return null
226
+ }
227
+ this.lastEvalAt = now
228
+
229
+ const evalInput: {
230
+ account_id: string
231
+ current_workflow?: string
232
+ recent_primitives: Array<{
233
+ primitive: CanonicalPrimitive
234
+ payload: Record<string, unknown>
235
+ occurred_at: string
236
+ }>
237
+ consumer: 'ui' | 'csm' | 'agent'
238
+ recent_sentiment?: 'positivo' | 'neutro' | 'negativo' | 'urgente'
239
+ } = {
240
+ account_id: this.config.accountId,
241
+ recent_primitives: [...this.ring],
242
+ consumer: this.config.consumer ?? 'ui',
243
+ }
244
+ if (this.activeWorkflow) evalInput.current_workflow = this.activeWorkflow
245
+ if (this.sentiment) evalInput.recent_sentiment = this.sentiment
246
+
247
+ const result = await this.transport.evaluate(evalInput)
248
+ if (!result) return null
249
+
250
+ this.listeners.forEach((l) => {
251
+ try {
252
+ l(result)
253
+ } catch (e) {
254
+ this.log('listener error', e)
255
+ }
256
+ })
257
+
258
+ if (result.decision_type === 'intervention' && this.shouldRender()) {
259
+ void this.renderDecision(result)
260
+ }
261
+
262
+ return result
263
+ }
264
+
265
+ private async renderDecision(decision: DecisionResult): Promise<void> {
266
+ const payload = decision.decision_payload as InterventionPayload
267
+ if (!payload || typeof payload.kind !== 'string' || typeof payload.text !== 'string') {
268
+ this.outcomeTracker?.dispatch(decision.decision_id, 'failed', { reason: 'invalid_payload' })
269
+ return
270
+ }
271
+
272
+ const handlers: RenderHandlers = {
273
+ onExecuted: (meta) => this.outcomeTracker?.dispatch(decision.decision_id, 'executed', meta),
274
+ onDismissed: (meta) => this.outcomeTracker?.dispatch(decision.decision_id, 'dismissed', meta),
275
+ onFailed: (meta) => this.outcomeTracker?.dispatch(decision.decision_id, 'failed', meta),
276
+ }
277
+
278
+ let control: RenderControl | null = null
279
+ try {
280
+ switch (payload.kind) {
281
+ case 'tooltip': {
282
+ const anchor = await resolveAnchor(payload.anchor_selector)
283
+ if (!anchor) {
284
+ this.outcomeTracker?.dispatch(decision.decision_id, 'no_show', {
285
+ reason: 'anchor_unresolved',
286
+ selector: payload.anchor_selector ?? null,
287
+ })
288
+ return
289
+ }
290
+ control = renderTooltip(payload, anchor, handlers)
291
+ break
292
+ }
293
+ case 'banner':
294
+ control = renderBanner(payload, handlers)
295
+ break
296
+ case 'cta':
297
+ control = renderCta(payload, handlers)
298
+ break
299
+ case 'modal':
300
+ control = renderModal(payload, handlers)
301
+ break
302
+ case 'checklist':
303
+ control = renderChecklist(payload, decision.decision_id, handlers)
304
+ break
305
+ case 'spotlight': {
306
+ const anchor = await resolveAnchor(payload.anchor_selector)
307
+ if (!anchor) {
308
+ this.outcomeTracker?.dispatch(decision.decision_id, 'no_show', {
309
+ reason: 'anchor_unresolved',
310
+ selector: payload.anchor_selector ?? null,
311
+ })
312
+ return
313
+ }
314
+ control = renderSpotlight(payload, anchor, handlers)
315
+ break
316
+ }
317
+ default:
318
+ this.outcomeTracker?.dispatch(decision.decision_id, 'no_show', {
319
+ reason: 'kind_not_supported',
320
+ kind: payload.kind,
321
+ })
322
+ return
323
+ }
324
+ } catch (e) {
325
+ this.log('render error', e)
326
+ handlers.onFailed({ error: String(e) })
327
+ return
328
+ }
329
+
330
+ if (!control) return
331
+
332
+ // Slot collision: store decide quem fica · sup ersedido é dismissado.
333
+ const superseded = this.store.claim(
334
+ payload.kind,
335
+ decision.decision_id,
336
+ decision.confidence,
337
+ payload.anchor_selector,
338
+ () => control!.dismiss('dismissed'),
339
+ )
340
+ if (superseded && superseded.decisionId !== decision.decision_id) {
341
+ superseded.dismiss()
342
+ this.outcomeTracker?.dispatch(superseded.decisionId, 'dismissed', {
343
+ reason: 'superseded',
344
+ by: decision.decision_id,
345
+ })
346
+ }
347
+
348
+ this.outcomeTracker?.registerPending(decision.decision_id, this.activeWorkflow ?? undefined)
349
+ appendToRoot(control.el)
350
+ }
351
+
352
+ private shouldRender(): boolean {
353
+ if (this.config?.renderInterventions === false) return false
354
+ if (this.runtime?.features && this.runtime.features.interventions_enabled === false) return false
355
+ return typeof document !== 'undefined'
356
+ }
357
+
358
+ private shouldTriggerEval(primitive: CanonicalPrimitive): boolean {
359
+ // Eventos que tipicamente disparam decisão (state-change ou friction).
360
+ return (
361
+ primitive === 'workflow.started' ||
362
+ primitive === 'workflow.blocked' ||
363
+ primitive === 'workflow.completed' ||
364
+ primitive === 'workflow.abandoned' ||
365
+ primitive === 'friction.detected' ||
366
+ primitive === 'support.sentiment_negative' ||
367
+ primitive === 'expansion.intent_detected' ||
368
+ primitive === 'expansion.ceiling_hit'
369
+ )
370
+ }
371
+
372
+ private pushRing(entry: RingEntry): void {
373
+ this.ring.push(entry)
374
+ while (this.ring.length > RING_BUFFER_LIMIT) this.ring.shift()
375
+ }
376
+
377
+ private themeFromRuntime(): ThemeConfig | undefined {
378
+ return this.runtime?.theme
379
+ }
380
+
381
+ private readCachedConfig(): RuntimeConfig | null {
382
+ if (typeof localStorage === 'undefined') return null
383
+ try {
384
+ const raw = localStorage.getItem(CONFIG_CACHE_KEY)
385
+ if (!raw) return null
386
+ const parsed = JSON.parse(raw) as { at: number; data: RuntimeConfig }
387
+ if (Date.now() - parsed.at > CONFIG_CACHE_TTL_MS) return null
388
+ return parsed.data
389
+ } catch {
390
+ return null
391
+ }
392
+ }
393
+
394
+ private writeCachedConfig(config: RuntimeConfig): void {
395
+ if (typeof localStorage === 'undefined') return
396
+ try {
397
+ localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify({ at: Date.now(), data: config }))
398
+ } catch {
399
+ // storage quota · ignore
400
+ }
401
+ }
402
+
403
+ private log(...args: unknown[]): void {
404
+ if (this.config?.debug && typeof console !== 'undefined') {
405
+ console.warn('[holdy]', ...args)
406
+ }
407
+ }
408
+ }
409
+
410
+ export const Holdy = new HoldySdk()
411
+
412
+ export type {
413
+ AccountIdentity,
414
+ CanonicalPrimitive,
415
+ DecisionResult,
416
+ DecisionListener,
417
+ HoldyConfig,
418
+ IdentifyOptions,
419
+ InterventionPayload,
420
+ OutcomeValue,
421
+ RuntimeConfig,
422
+ ThemeConfig,
423
+ TrackOptions,
424
+ UserIdentity,
425
+ } from './types'
426
+
427
+ export default Holdy
package/src/outcome.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { OutcomeValue } from './types'
2
+ import type { Transport } from './transport'
3
+
4
+ interface PendingDecision {
5
+ id: string
6
+ workflow?: string | undefined
7
+ startedAt: number
8
+ }
9
+
10
+ /**
11
+ * Outcome tracker · explicit only.
12
+ *
13
+ * - executed → ack via UI handler.
14
+ * - dismissed → ack via UI handler.
15
+ * - no_show → SDK detecta anchor não resolvido em 5s.
16
+ * - failed → exception capturada pelo render boundary.
17
+ * - converted → conversion cron server-side faz join workflow.completed.
18
+ */
19
+ export class OutcomeTracker {
20
+ private readonly transport: Transport
21
+ private readonly pending = new Map<string, PendingDecision>()
22
+
23
+ constructor(transport: Transport) {
24
+ this.transport = transport
25
+ }
26
+
27
+ registerPending(decisionId: string, workflow: string | undefined): void {
28
+ this.pending.set(decisionId, {
29
+ id: decisionId,
30
+ workflow,
31
+ startedAt: Date.now(),
32
+ })
33
+ }
34
+
35
+ dispatch(decisionId: string, outcome: OutcomeValue, metadata?: Record<string, unknown>): void {
36
+ if (!this.pending.has(decisionId) && outcome !== 'failed') {
37
+ // unknown decision · still report failed/dismissed since they're idempotent
38
+ this.transport.reportOutcome(decisionId, outcome, metadata)
39
+ return
40
+ }
41
+ this.pending.delete(decisionId)
42
+ this.transport.reportOutcome(decisionId, outcome, {
43
+ ...metadata,
44
+ latency_ms: metadata?.latency_ms ?? Date.now() - (this.pending.get(decisionId)?.startedAt ?? Date.now()),
45
+ })
46
+ }
47
+
48
+ hasPending(decisionId: string): boolean {
49
+ return this.pending.has(decisionId)
50
+ }
51
+ }