@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/store.ts ADDED
@@ -0,0 +1,106 @@
1
+ import type { InterventionKind } from './types'
2
+
3
+ interface ActiveSlot {
4
+ decisionId: string
5
+ confidence: number
6
+ kind: InterventionKind
7
+ dismiss: () => void
8
+ }
9
+
10
+ /**
11
+ * Active intervention slots · prioridade por confidence.
12
+ *
13
+ * Caps globais:
14
+ * - 1 banner global
15
+ * - 1 modal global (Wave 2 · UX bloqueia tudo, único)
16
+ * - 1 checklist global (Wave 2 · sticky bottom-right)
17
+ * - N tooltips, 1 por anchor
18
+ * - N spotlights, 1 por anchor (Wave 2)
19
+ * - 1 CTA global (sticky bottom-right)
20
+ *
21
+ * Modal e checklist + cta competem pelo mesmo "espaço mental" do usuário ·
22
+ * a próxima implementação pode consolidar (1 floating element no canto).
23
+ * Wave 2 mantém slots separados pra simplicidade.
24
+ *
25
+ * Quando 2 decisions reivindicam o mesmo slot, a de menor confidence perde.
26
+ */
27
+ export class InterventionStore {
28
+ private readonly tooltipsByAnchor = new Map<string, ActiveSlot>()
29
+ private readonly spotlightsByAnchor = new Map<string, ActiveSlot>()
30
+ private banner: ActiveSlot | null = null
31
+ private modal: ActiveSlot | null = null
32
+ private checklist: ActiveSlot | null = null
33
+ private cta: ActiveSlot | null = null
34
+
35
+ /** Tenta reivindicar slot. Retorna previous slot que precisa ser dismissado (caller). */
36
+ claim(
37
+ kind: InterventionKind,
38
+ decisionId: string,
39
+ confidence: number,
40
+ anchorSelector: string | undefined,
41
+ dismiss: () => void,
42
+ ): ActiveSlot | null {
43
+ const slot: ActiveSlot = { decisionId, confidence, kind, dismiss }
44
+
45
+ if (kind === 'tooltip') {
46
+ const key = anchorSelector ?? '__no_anchor__'
47
+ const prev = this.tooltipsByAnchor.get(key)
48
+ if (prev && prev.confidence >= confidence) return slot
49
+ this.tooltipsByAnchor.set(key, slot)
50
+ return prev ?? null
51
+ }
52
+
53
+ if (kind === 'spotlight') {
54
+ const key = anchorSelector ?? '__no_anchor__'
55
+ const prev = this.spotlightsByAnchor.get(key)
56
+ if (prev && prev.confidence >= confidence) return slot
57
+ this.spotlightsByAnchor.set(key, slot)
58
+ return prev ?? null
59
+ }
60
+
61
+ if (kind === 'banner') {
62
+ const prev = this.banner
63
+ if (prev && prev.confidence >= confidence) return slot
64
+ this.banner = slot
65
+ return prev ?? null
66
+ }
67
+
68
+ if (kind === 'modal') {
69
+ const prev = this.modal
70
+ if (prev && prev.confidence >= confidence) return slot
71
+ this.modal = slot
72
+ return prev ?? null
73
+ }
74
+
75
+ if (kind === 'checklist') {
76
+ const prev = this.checklist
77
+ if (prev && prev.confidence >= confidence) return slot
78
+ this.checklist = slot
79
+ return prev ?? null
80
+ }
81
+
82
+ if (kind === 'cta') {
83
+ const prev = this.cta
84
+ if (prev && prev.confidence >= confidence) return slot
85
+ this.cta = slot
86
+ return prev ?? null
87
+ }
88
+
89
+ return null
90
+ }
91
+
92
+ release(decisionId: string): void {
93
+ if (this.banner?.decisionId === decisionId) this.banner = null
94
+ if (this.modal?.decisionId === decisionId) this.modal = null
95
+ if (this.checklist?.decisionId === decisionId) this.checklist = null
96
+ if (this.cta?.decisionId === decisionId) this.cta = null
97
+ for (const map of [this.tooltipsByAnchor, this.spotlightsByAnchor]) {
98
+ for (const [k, slot] of map) {
99
+ if (slot.decisionId === decisionId) {
100
+ map.delete(k)
101
+ break
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,182 @@
1
+ import type {
2
+ AccountIdentity,
3
+ CanonicalPrimitive,
4
+ DecisionResult,
5
+ HoldyConfig,
6
+ RuntimeConfig,
7
+ UserIdentity,
8
+ } from './types'
9
+ import { EventQueue } from './queue'
10
+
11
+ const DEFAULT_API_URL = 'https://app.holdy.com.br/api/v1'
12
+
13
+ export class Transport {
14
+ private readonly apiKey: string
15
+ private readonly apiUrl: string
16
+ private readonly debug: boolean
17
+ private readonly queue: EventQueue
18
+
19
+ constructor(config: HoldyConfig) {
20
+ this.apiKey = config.apiKey
21
+ this.apiUrl = (config.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, '')
22
+ this.debug = config.debug ?? false
23
+ this.queue = new EventQueue(
24
+ (body) => this.sendBatch(body.events),
25
+ (...args) => this.log(...args),
26
+ )
27
+ }
28
+
29
+ private headers(): Record<string, string> {
30
+ return {
31
+ 'content-type': 'application/json',
32
+ authorization: `Bearer ${this.apiKey}`,
33
+ }
34
+ }
35
+
36
+ async fetchRuntimeConfig(): Promise<RuntimeConfig | null> {
37
+ try {
38
+ const res = await fetch(`${this.apiUrl}/runtime/config`, {
39
+ method: 'GET',
40
+ headers: this.headers(),
41
+ credentials: 'omit',
42
+ mode: 'cors',
43
+ })
44
+ if (!res.ok) {
45
+ this.log('runtime/config failed', res.status)
46
+ return null
47
+ }
48
+ return (await res.json()) as RuntimeConfig
49
+ } catch (e) {
50
+ this.log('runtime/config error', e)
51
+ return null
52
+ }
53
+ }
54
+
55
+ async evaluate(input: {
56
+ account_id: string
57
+ current_workflow?: string
58
+ recent_primitives?: Array<{
59
+ primitive: CanonicalPrimitive
60
+ payload: Record<string, unknown>
61
+ occurred_at: string
62
+ }>
63
+ recent_sentiment?: 'positivo' | 'neutro' | 'negativo' | 'urgente'
64
+ consumer?: 'ui' | 'csm' | 'agent'
65
+ }): Promise<DecisionResult | null> {
66
+ try {
67
+ const res = await fetch(`${this.apiUrl}/decisions/evaluate`, {
68
+ method: 'POST',
69
+ headers: this.headers(),
70
+ body: JSON.stringify({ consumer: 'ui', ...input }),
71
+ credentials: 'omit',
72
+ mode: 'cors',
73
+ })
74
+ if (!res.ok) {
75
+ this.log('decisions/evaluate failed', res.status)
76
+ return null
77
+ }
78
+ return (await res.json()) as DecisionResult
79
+ } catch (e) {
80
+ this.log('decisions/evaluate error', e)
81
+ return null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Event ingest · delegado pra EventQueue (IndexedDB + exponential backoff).
87
+ * Tenta envio imediato; se falhar, persiste e retry em background.
88
+ */
89
+ ingestBatch(events: Array<{
90
+ primitive: CanonicalPrimitive
91
+ occurred_at: string
92
+ account_id?: string
93
+ user_id?: string
94
+ session_id?: string
95
+ payload: Record<string, unknown>
96
+ idempotency_key?: string
97
+ }>): void {
98
+ if (events.length === 0) return
99
+ void this.queue.enqueue(events)
100
+ }
101
+
102
+ /**
103
+ * Send raw · usado pelo queue. Retorna true em sucesso (2xx), false em
104
+ * falha (rede ou 4xx/5xx).
105
+ *
106
+ * IMPORTANTE: 4xx é tratado como falha permanente — não vale retry. Mas
107
+ * a fila não distingue (mantém pra retry simples). MAX_ATTEMPTS = 5 limita
108
+ * o desperdício. Refinamento (4xx → drop imediato) fica pra Wave 3.
109
+ */
110
+ private async sendBatch(events: unknown[]): Promise<boolean> {
111
+ try {
112
+ const res = await fetch(`${this.apiUrl}/events/batch`, {
113
+ method: 'POST',
114
+ headers: this.headers(),
115
+ body: JSON.stringify({ events }),
116
+ credentials: 'omit',
117
+ mode: 'cors',
118
+ keepalive: true,
119
+ })
120
+ return res.ok
121
+ } catch (e) {
122
+ this.log('events/batch network error', e)
123
+ return false
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Upsert account + opcional user via /v1/accounts/upsert.
129
+ *
130
+ * Best-effort fire-and-forget. Backend COALESCE não sobrescreve campos não-nulos.
131
+ * Retorna account_id (UUID Holdy) pra hidratar config local; null em falha.
132
+ */
133
+ async upsertAccount(payload: {
134
+ account: AccountIdentity
135
+ user?: UserIdentity
136
+ }): Promise<{ account_id: string; account_user_id: string | null } | null> {
137
+ try {
138
+ const res = await fetch(`${this.apiUrl}/accounts/upsert`, {
139
+ method: 'POST',
140
+ headers: this.headers(),
141
+ body: JSON.stringify(payload),
142
+ credentials: 'omit',
143
+ mode: 'cors',
144
+ keepalive: true,
145
+ })
146
+ if (!res.ok) {
147
+ this.log('accounts/upsert failed', res.status)
148
+ return null
149
+ }
150
+ return (await res.json()) as { account_id: string; account_user_id: string | null }
151
+ } catch (e) {
152
+ this.log('accounts/upsert error', e)
153
+ return null
154
+ }
155
+ }
156
+
157
+ reportOutcome(
158
+ decisionId: string,
159
+ outcome: 'executed' | 'dismissed' | 'converted' | 'no_show' | 'failed',
160
+ metadata?: Record<string, unknown>,
161
+ ): void {
162
+ const url = `${this.apiUrl}/decisions/${encodeURIComponent(decisionId)}/outcome`
163
+ fetch(url, {
164
+ method: 'POST',
165
+ headers: this.headers(),
166
+ body: JSON.stringify({ outcome, metadata }),
167
+ credentials: 'omit',
168
+ mode: 'cors',
169
+ keepalive: true,
170
+ }).catch((e) => this.log('decisions/outcome error', e))
171
+ }
172
+
173
+ private log(...args: unknown[]): void {
174
+ if (this.debug && typeof console !== 'undefined') {
175
+ console.warn('[holdy]', ...args)
176
+ }
177
+ }
178
+
179
+ destroy(): void {
180
+ this.queue.destroy()
181
+ }
182
+ }
package/src/types.ts ADDED
@@ -0,0 +1,173 @@
1
+ // Public types — keep in sync with backend canonical-v1.ts + decisions/engine.ts.
2
+
3
+ export type CanonicalPrimitive =
4
+ | 'workflow.started'
5
+ | 'workflow.progressed'
6
+ | 'workflow.blocked'
7
+ | 'workflow.completed'
8
+ | 'workflow.abandoned'
9
+ | 'outcome.progressed'
10
+ | 'outcome.completed'
11
+ | 'outcome.regressed'
12
+ | 'friction.detected'
13
+ | 'collaboration.invited'
14
+ | 'collaboration.joined'
15
+ | 'collaboration.deepened'
16
+ | 'collaboration.weakened'
17
+ | 'collaboration.champion_lost'
18
+ | 'support.escalated'
19
+ | 'support.sentiment_negative'
20
+ | 'support.theme_detected'
21
+ | 'expansion.intent_detected'
22
+ | 'expansion.ceiling_hit'
23
+ | 'memory.append'
24
+ | 'session.activity'
25
+
26
+ export type DecisionType =
27
+ | 'intervention'
28
+ | 'briefing'
29
+ | 'cs_task'
30
+ | 'track_journey'
31
+ | 'no_action'
32
+
33
+ export type InterventionKind =
34
+ | 'tooltip'
35
+ | 'banner'
36
+ | 'modal'
37
+ | 'checklist'
38
+ | 'spotlight'
39
+ | 'cta'
40
+
41
+ export interface ChecklistItem {
42
+ /** ID estável · persistência local + dedup. */
43
+ id: string
44
+ label: string
45
+ /** Opcional · link aberto ao clicar. Sem href, item só marca completed. */
46
+ href?: string
47
+ /** Workflow ou outcome primitive que marca esse item como completed quando emitido. */
48
+ completed_event?: string
49
+ /** Pré-marcado · server pode indicar que cliente já fez essa etapa. */
50
+ completed?: boolean
51
+ }
52
+
53
+ export interface InterventionPayload {
54
+ kind: InterventionKind
55
+ text: string
56
+ /** Título opcional · usado em modal/checklist headers. */
57
+ title?: string
58
+ cta_text?: string
59
+ cta_href?: string
60
+ anchor_selector?: string
61
+ /** Items pro kind=checklist. */
62
+ items?: ChecklistItem[]
63
+ }
64
+
65
+ export interface DecisionResult {
66
+ decision_id: string
67
+ decision_type: DecisionType
68
+ decision_payload: InterventionPayload | Record<string, unknown>
69
+ rule_key: string
70
+ confidence: number
71
+ reason: string
72
+ decided_at: string
73
+ }
74
+
75
+ export type OutcomeValue =
76
+ | 'executed'
77
+ | 'dismissed'
78
+ | 'converted'
79
+ | 'no_show'
80
+ | 'failed'
81
+
82
+ export interface RuntimeConfig {
83
+ api_version: string
84
+ taxonomy_version: string
85
+ organization_id: string
86
+ thresholds: Record<string, number | string>
87
+ features: {
88
+ auto_attribution: boolean
89
+ interventions_enabled: boolean
90
+ consent_required: boolean
91
+ }
92
+ theme?: ThemeConfig
93
+ }
94
+
95
+ export interface ThemeConfig {
96
+ primary_color: string
97
+ font_family: string
98
+ border_radius_px: number
99
+ position_offset_px: number
100
+ }
101
+
102
+ export const DEFAULT_THEME: ThemeConfig = {
103
+ primary_color: '#ca2121',
104
+ font_family: 'inherit',
105
+ border_radius_px: 8,
106
+ position_offset_px: 8,
107
+ }
108
+
109
+ export interface HoldyConfig {
110
+ apiKey: string
111
+ apiUrl?: string
112
+ accountId?: string
113
+ userId?: string
114
+ sessionId?: string
115
+ consumer?: 'ui' | 'csm' | 'agent'
116
+ debug?: boolean
117
+ /** Disables render runtime · SDK só captura telemetry. */
118
+ renderInterventions?: boolean
119
+ /**
120
+ * Stage 28 · auto-capture de page views (initial_load + route_change +
121
+ * visibility_change). Quando true, SDK monkeypatcha history.pushState/
122
+ * replaceState + escuta popstate/visibilitychange e emite session.activity
123
+ * sem código manual. Default false pra back-compat.
124
+ */
125
+ autoCapture?: boolean
126
+ }
127
+
128
+ /**
129
+ * Rich account metadata enviado pra /v1/accounts/upsert.
130
+ * Backend usa COALESCE — não sobrescreve campos preenchidos no wizard manual.
131
+ *
132
+ * `external_id` é o identificador do cliente (string opaca · ex "acct_abc123").
133
+ * Backend resolve via customer_identities(source='sdk') → accounts.id.
134
+ */
135
+ export interface AccountIdentity {
136
+ external_id: string
137
+ name?: string
138
+ logo_url?: string
139
+ domain?: string
140
+ plan?: string
141
+ mrr?: number
142
+ contract_start?: string
143
+ contract_end?: string
144
+ lifecycle_stage?: 'onboarding' | 'adoption' | 'expansion' | 'renewal' | 'churned'
145
+ traits?: Record<string, string | number | boolean | null>
146
+ }
147
+
148
+ export interface UserIdentity {
149
+ external_id: string
150
+ email?: string
151
+ name?: string
152
+ job_title?: string
153
+ role?: 'decision_maker' | 'champion' | 'admin' | 'user' | 'unknown'
154
+ }
155
+
156
+ export interface IdentifyOptions {
157
+ /** ID interno Holdy (UUID) · opcional · usado pra retro-compat. */
158
+ accountId?: string
159
+ userId?: string
160
+ sessionId?: string
161
+ /** Metadata rica · gera best-effort upsert em /v1/accounts/upsert. */
162
+ account?: AccountIdentity
163
+ user?: UserIdentity
164
+ }
165
+
166
+ export interface TrackOptions {
167
+ /** Pula chamada implícita ao decisions/evaluate. */
168
+ skipEval?: boolean
169
+ /** Workflow corrente · override do estado interno. */
170
+ workflow?: string
171
+ }
172
+
173
+ export type DecisionListener = (decision: DecisionResult) => void