@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
@@ -0,0 +1,114 @@
1
+ export type CanonicalPrimitive = 'workflow.started' | 'workflow.progressed' | 'workflow.blocked' | 'workflow.completed' | 'workflow.abandoned' | 'outcome.progressed' | 'outcome.completed' | 'outcome.regressed' | 'friction.detected' | 'collaboration.invited' | 'collaboration.joined' | 'collaboration.deepened' | 'collaboration.weakened' | 'collaboration.champion_lost' | 'support.escalated' | 'support.sentiment_negative' | 'support.theme_detected' | 'expansion.intent_detected' | 'expansion.ceiling_hit' | 'memory.append' | 'session.activity';
2
+ export type DecisionType = 'intervention' | 'briefing' | 'cs_task' | 'track_journey' | 'no_action';
3
+ export type InterventionKind = 'tooltip' | 'banner' | 'modal' | 'checklist' | 'spotlight' | 'cta';
4
+ export interface ChecklistItem {
5
+ /** ID estável · persistência local + dedup. */
6
+ id: string;
7
+ label: string;
8
+ /** Opcional · link aberto ao clicar. Sem href, item só marca completed. */
9
+ href?: string;
10
+ /** Workflow ou outcome primitive que marca esse item como completed quando emitido. */
11
+ completed_event?: string;
12
+ /** Pré-marcado · server pode indicar que cliente já fez essa etapa. */
13
+ completed?: boolean;
14
+ }
15
+ export interface InterventionPayload {
16
+ kind: InterventionKind;
17
+ text: string;
18
+ /** Título opcional · usado em modal/checklist headers. */
19
+ title?: string;
20
+ cta_text?: string;
21
+ cta_href?: string;
22
+ anchor_selector?: string;
23
+ /** Items pro kind=checklist. */
24
+ items?: ChecklistItem[];
25
+ }
26
+ export interface DecisionResult {
27
+ decision_id: string;
28
+ decision_type: DecisionType;
29
+ decision_payload: InterventionPayload | Record<string, unknown>;
30
+ rule_key: string;
31
+ confidence: number;
32
+ reason: string;
33
+ decided_at: string;
34
+ }
35
+ export type OutcomeValue = 'executed' | 'dismissed' | 'converted' | 'no_show' | 'failed';
36
+ export interface RuntimeConfig {
37
+ api_version: string;
38
+ taxonomy_version: string;
39
+ organization_id: string;
40
+ thresholds: Record<string, number | string>;
41
+ features: {
42
+ auto_attribution: boolean;
43
+ interventions_enabled: boolean;
44
+ consent_required: boolean;
45
+ };
46
+ theme?: ThemeConfig;
47
+ }
48
+ export interface ThemeConfig {
49
+ primary_color: string;
50
+ font_family: string;
51
+ border_radius_px: number;
52
+ position_offset_px: number;
53
+ }
54
+ export declare const DEFAULT_THEME: ThemeConfig;
55
+ export interface HoldyConfig {
56
+ apiKey: string;
57
+ apiUrl?: string;
58
+ accountId?: string;
59
+ userId?: string;
60
+ sessionId?: string;
61
+ consumer?: 'ui' | 'csm' | 'agent';
62
+ debug?: boolean;
63
+ /** Disables render runtime · SDK só captura telemetry. */
64
+ renderInterventions?: boolean;
65
+ /**
66
+ * Stage 28 · auto-capture de page views (initial_load + route_change +
67
+ * visibility_change). Quando true, SDK monkeypatcha history.pushState/
68
+ * replaceState + escuta popstate/visibilitychange e emite session.activity
69
+ * sem código manual. Default false pra back-compat.
70
+ */
71
+ autoCapture?: boolean;
72
+ }
73
+ /**
74
+ * Rich account metadata enviado pra /v1/accounts/upsert.
75
+ * Backend usa COALESCE — não sobrescreve campos preenchidos no wizard manual.
76
+ *
77
+ * `external_id` é o identificador do cliente (string opaca · ex "acct_abc123").
78
+ * Backend resolve via customer_identities(source='sdk') → accounts.id.
79
+ */
80
+ export interface AccountIdentity {
81
+ external_id: string;
82
+ name?: string;
83
+ logo_url?: string;
84
+ domain?: string;
85
+ plan?: string;
86
+ mrr?: number;
87
+ contract_start?: string;
88
+ contract_end?: string;
89
+ lifecycle_stage?: 'onboarding' | 'adoption' | 'expansion' | 'renewal' | 'churned';
90
+ traits?: Record<string, string | number | boolean | null>;
91
+ }
92
+ export interface UserIdentity {
93
+ external_id: string;
94
+ email?: string;
95
+ name?: string;
96
+ job_title?: string;
97
+ role?: 'decision_maker' | 'champion' | 'admin' | 'user' | 'unknown';
98
+ }
99
+ export interface IdentifyOptions {
100
+ /** ID interno Holdy (UUID) · opcional · usado pra retro-compat. */
101
+ accountId?: string;
102
+ userId?: string;
103
+ sessionId?: string;
104
+ /** Metadata rica · gera best-effort upsert em /v1/accounts/upsert. */
105
+ account?: AccountIdentity;
106
+ user?: UserIdentity;
107
+ }
108
+ export interface TrackOptions {
109
+ /** Pula chamada implícita ao decisions/evaluate. */
110
+ skipEval?: boolean;
111
+ /** Workflow corrente · override do estado interno. */
112
+ workflow?: string;
113
+ }
114
+ export type DecisionListener = (decision: DecisionResult) => void;
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@holdyapp/sdk",
3
+ "version": "0.2.0",
4
+ "description": "Holdy SDK · behavioral telemetry + in-product interventions",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "homepage": "https://app.holdy.com.br",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/augustoperolligaedke/holdymvp.git",
11
+ "directory": "sdk"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/augustoperolligaedke/holdymvp/issues"
15
+ },
16
+ "keywords": [
17
+ "customer-success",
18
+ "behavioral-analytics",
19
+ "in-product",
20
+ "interventions",
21
+ "holdy"
22
+ ],
23
+ "type": "module",
24
+ "main": "./dist/holdy.cjs.js",
25
+ "module": "./dist/holdy.esm.js",
26
+ "browser": "./dist/holdy.iife.js",
27
+ "types": "./dist/index.d.ts",
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/holdy.esm.js",
38
+ "require": "./dist/holdy.cjs.js",
39
+ "default": "./dist/holdy.esm.js"
40
+ }
41
+ },
42
+ "scripts": {
43
+ "build:js": "node build.mjs",
44
+ "build:types": "tsc --emitDeclarationOnly",
45
+ "build": "npm run build:js && npm run build:types",
46
+ "type-check": "tsc --noEmit",
47
+ "prepublishOnly": "npm run build"
48
+ },
49
+ "devDependencies": {
50
+ "esbuild": "^0.25.0",
51
+ "typescript": "^5.9.0"
52
+ },
53
+ "sideEffects": false,
54
+ "engines": {
55
+ "node": ">=18"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }
package/src/anchor.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Anchor resolver · MutationObserver-based.
3
+ *
4
+ * Cliente do produto pode renderizar o anchor depois do decision chegar
5
+ * (SPA route, lazy mount). Resolver espera até 5s antes de desistir.
6
+ * Single observer global · cap retries por selector.
7
+ */
8
+
9
+ const RESOLVE_TIMEOUT_MS = 5000
10
+
11
+ interface PendingAnchor {
12
+ selector: string
13
+ resolve: (el: Element | null) => void
14
+ cleanup: () => void
15
+ }
16
+
17
+ const pending = new Set<PendingAnchor>()
18
+ let observer: MutationObserver | null = null
19
+
20
+ function ensureObserver(): void {
21
+ if (observer || typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
22
+ observer = new MutationObserver(() => {
23
+ if (pending.size === 0) return
24
+ pending.forEach((entry) => {
25
+ const el = document.querySelector(entry.selector)
26
+ if (el) {
27
+ entry.cleanup()
28
+ entry.resolve(el)
29
+ }
30
+ })
31
+ if (pending.size === 0) stopObserver()
32
+ })
33
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['id', 'class', 'data-holdy-anchor'] })
34
+ }
35
+
36
+ function stopObserver(): void {
37
+ if (observer) {
38
+ observer.disconnect()
39
+ observer = null
40
+ }
41
+ }
42
+
43
+ export function resolveAnchor(selector: string | undefined): Promise<Element | null> {
44
+ if (!selector) return Promise.resolve(null)
45
+ if (typeof document === 'undefined') return Promise.resolve(null)
46
+
47
+ // Try sync first
48
+ const existing = document.querySelector(selector)
49
+ if (existing) return Promise.resolve(existing)
50
+
51
+ return new Promise<Element | null>((resolve) => {
52
+ let resolved = false
53
+ const entry: PendingAnchor = {
54
+ selector,
55
+ resolve,
56
+ cleanup: () => {
57
+ if (resolved) return
58
+ resolved = true
59
+ pending.delete(entry)
60
+ clearTimeout(timeoutId)
61
+ if (pending.size === 0) stopObserver()
62
+ },
63
+ }
64
+ const timeoutId = setTimeout(() => {
65
+ entry.cleanup()
66
+ resolve(null)
67
+ }, RESOLVE_TIMEOUT_MS)
68
+
69
+ pending.add(entry)
70
+ ensureObserver()
71
+ })
72
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Auto-capture · Stage 28 G_auto.
3
+ *
4
+ * Quando HoldyConfig.autoCapture=true, instalamos hooks globais que
5
+ * emitem `session.activity` automaticamente:
6
+ *
7
+ * · initial_load · 1x ao init
8
+ * · route_change · cada history.pushState/replaceState/popstate
9
+ * · visibility_change · página voltou a ficar visível
10
+ *
11
+ * Tudo client-side · não precisa que o customer declare nada. Permite que
12
+ * o widget de install do Holdy mostre "✓ conectado" segundos após o user
13
+ * carregar a página.
14
+ *
15
+ * Idempotente · re-rodar `installAutoCapture` é no-op via guard global.
16
+ * Cleanup ao destroy via array de unsubscribers retornado.
17
+ */
18
+
19
+ export interface AutoCaptureEmit {
20
+ (path: string, title: string | undefined, referrer: string | undefined, kind: 'initial_load' | 'route_change' | 'visibility_change'): void
21
+ }
22
+
23
+ const INSTALLED_FLAG = '__holdy_autocapture_installed__'
24
+ const ROUTE_DEBOUNCE_MS = 50
25
+
26
+ interface GlobalWithFlag {
27
+ [INSTALLED_FLAG]?: boolean
28
+ history?: History
29
+ }
30
+
31
+ export function installAutoCapture(emit: AutoCaptureEmit): () => void {
32
+ if (typeof window === 'undefined' || typeof history === 'undefined') {
33
+ return () => undefined
34
+ }
35
+ const w = window as unknown as GlobalWithFlag
36
+ if (w[INSTALLED_FLAG]) {
37
+ return () => undefined
38
+ }
39
+ w[INSTALLED_FLAG] = true
40
+
41
+ const cleanups: Array<() => void> = []
42
+ let lastPath = currentPath()
43
+ let pendingRouteEmit: ReturnType<typeof setTimeout> | null = null
44
+
45
+ function safeEmit(kind: 'initial_load' | 'route_change' | 'visibility_change'): void {
46
+ try {
47
+ const path = currentPath()
48
+ const title = typeof document !== 'undefined' ? document.title : undefined
49
+ const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined
50
+ emit(path, title, referrer, kind)
51
+ } catch {
52
+ // never throw from auto-capture · não pode quebrar a app do cliente
53
+ }
54
+ }
55
+
56
+ function scheduleRouteEmit(): void {
57
+ // Debounce: pushState seguido de replaceState é 1 mudança lógica.
58
+ if (pendingRouteEmit) clearTimeout(pendingRouteEmit)
59
+ pendingRouteEmit = setTimeout(() => {
60
+ const path = currentPath()
61
+ if (path !== lastPath) {
62
+ lastPath = path
63
+ safeEmit('route_change')
64
+ }
65
+ pendingRouteEmit = null
66
+ }, ROUTE_DEBOUNCE_MS)
67
+ }
68
+
69
+ // 1) Initial load
70
+ safeEmit('initial_load')
71
+
72
+ // 2) Monkeypatch history.pushState/replaceState (SPA SDR navigation)
73
+ const origPush = history.pushState
74
+ const origReplace = history.replaceState
75
+ history.pushState = function patchedPush(...args: Parameters<History['pushState']>) {
76
+ const result = origPush.apply(this, args)
77
+ scheduleRouteEmit()
78
+ return result
79
+ }
80
+ history.replaceState = function patchedReplace(...args: Parameters<History['replaceState']>) {
81
+ const result = origReplace.apply(this, args)
82
+ scheduleRouteEmit()
83
+ return result
84
+ }
85
+ cleanups.push(() => {
86
+ history.pushState = origPush
87
+ history.replaceState = origReplace
88
+ })
89
+
90
+ // 3) popstate · botão voltar/avançar do browser
91
+ const popHandler = (): void => scheduleRouteEmit()
92
+ window.addEventListener('popstate', popHandler)
93
+ cleanups.push(() => window.removeEventListener('popstate', popHandler))
94
+
95
+ // 4) hashchange · single-page apps que usam hash routing (router antigo)
96
+ const hashHandler = (): void => scheduleRouteEmit()
97
+ window.addEventListener('hashchange', hashHandler)
98
+ cleanups.push(() => window.removeEventListener('hashchange', hashHandler))
99
+
100
+ // 5) visibility_change · tab voltou a ficar visível depois de >5min
101
+ let hiddenAt: number | null = null
102
+ const visHandler = (): void => {
103
+ if (typeof document === 'undefined') return
104
+ if (document.visibilityState === 'hidden') {
105
+ hiddenAt = Date.now()
106
+ } else if (document.visibilityState === 'visible' && hiddenAt) {
107
+ const elapsed = Date.now() - hiddenAt
108
+ hiddenAt = null
109
+ // Re-emit só se ficou hidden >5min · evita flood quando user troca aba rapidinho
110
+ if (elapsed > 5 * 60_000) safeEmit('visibility_change')
111
+ }
112
+ }
113
+ if (typeof document !== 'undefined') {
114
+ document.addEventListener('visibilitychange', visHandler)
115
+ cleanups.push(() => document.removeEventListener('visibilitychange', visHandler))
116
+ }
117
+
118
+ // Final cleanup function · usado pelo destroy()
119
+ return () => {
120
+ if (pendingRouteEmit) clearTimeout(pendingRouteEmit)
121
+ for (const c of cleanups) c()
122
+ w[INSTALLED_FLAG] = false
123
+ }
124
+ }
125
+
126
+ function currentPath(): string {
127
+ if (typeof window === 'undefined' || !window.location) return '/'
128
+ return window.location.pathname + window.location.search + window.location.hash
129
+ }