@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.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/anchor.d.ts +8 -0
- package/dist/auto-capture.d.ts +21 -0
- package/dist/holdy.cjs.js +255 -0
- package/dist/holdy.cjs.js.map +7 -0
- package/dist/holdy.esm.js +255 -0
- package/dist/holdy.esm.js.map +7 -0
- package/dist/holdy.iife.js +255 -0
- package/dist/holdy.iife.js.map +7 -0
- package/dist/index.d.ts +46 -0
- package/dist/outcome.d.ts +19 -0
- package/dist/queue.d.ts +54 -0
- package/dist/render/kinds/banner.d.ts +3 -0
- package/dist/render/kinds/checklist.d.ts +19 -0
- package/dist/render/kinds/cta.d.ts +8 -0
- package/dist/render/kinds/modal.d.ts +16 -0
- package/dist/render/kinds/shared.d.ts +16 -0
- package/dist/render/kinds/spotlight.d.ts +17 -0
- package/dist/render/kinds/tooltip.d.ts +3 -0
- package/dist/render/root.d.ts +3 -0
- package/dist/render/styles.d.ts +5 -0
- package/dist/store.d.ts +36 -0
- package/dist/transport.d.ts +59 -0
- package/dist/types.d.ts +114 -0
- package/package.json +60 -0
- package/src/anchor.ts +72 -0
- package/src/auto-capture.ts +129 -0
- package/src/index.ts +427 -0
- package/src/outcome.ts +51 -0
- package/src/queue.ts +296 -0
- package/src/render/kinds/banner.ts +52 -0
- package/src/render/kinds/checklist.ts +175 -0
- package/src/render/kinds/cta.ts +53 -0
- package/src/render/kinds/modal.ts +129 -0
- package/src/render/kinds/shared.ts +67 -0
- package/src/render/kinds/spotlight.ts +176 -0
- package/src/render/kinds/tooltip.ts +82 -0
- package/src/render/root.ts +69 -0
- package/src/render/styles.ts +280 -0
- package/src/store.ts +106 -0
- package/src/transport.ts +182 -0
- package/src/types.ts +173 -0
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|