@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.
Files changed (75) hide show
  1. package/README.md +20 -147
  2. package/dist/auto-capture.d.ts +12 -16
  3. package/dist/auto-capture.d.ts.map +1 -0
  4. package/dist/browser.d.ts +23 -0
  5. package/dist/browser.d.ts.map +1 -0
  6. package/dist/core.d.ts +42 -0
  7. package/dist/core.d.ts.map +1 -0
  8. package/dist/holdy.js +2 -0
  9. package/dist/holdy.js.map +7 -0
  10. package/dist/index.cjs +430 -0
  11. package/dist/index.cjs.map +7 -0
  12. package/dist/index.d.ts +5 -46
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.mjs +399 -0
  15. package/dist/index.mjs.map +7 -0
  16. package/dist/server.cjs +177 -0
  17. package/dist/server.cjs.map +7 -0
  18. package/dist/server.d.ts +15 -0
  19. package/dist/server.d.ts.map +1 -0
  20. package/dist/server.mjs +148 -0
  21. package/dist/server.mjs.map +7 -0
  22. package/dist/types.d.ts +41 -109
  23. package/dist/types.d.ts.map +1 -0
  24. package/dist/web-context.d.ts +51 -0
  25. package/dist/web-context.d.ts.map +1 -0
  26. package/dist/web.d.ts +41 -0
  27. package/dist/web.d.ts.map +1 -0
  28. package/dist/web.mjs +389 -0
  29. package/dist/web.mjs.map +7 -0
  30. package/package.json +24 -20
  31. package/src/auto-capture.ts +51 -78
  32. package/src/browser.ts +55 -0
  33. package/src/core.test.ts +147 -0
  34. package/src/core.ts +172 -0
  35. package/src/index.ts +4 -427
  36. package/src/server.ts +19 -0
  37. package/src/types.ts +38 -162
  38. package/src/web-context.test.ts +71 -0
  39. package/src/web-context.ts +227 -0
  40. package/src/web.ts +108 -0
  41. package/LICENSE +0 -21
  42. package/dist/anchor.d.ts +0 -8
  43. package/dist/holdy.cjs.js +0 -255
  44. package/dist/holdy.cjs.js.map +0 -7
  45. package/dist/holdy.esm.js +0 -255
  46. package/dist/holdy.esm.js.map +0 -7
  47. package/dist/holdy.iife.js +0 -255
  48. package/dist/holdy.iife.js.map +0 -7
  49. package/dist/outcome.d.ts +0 -19
  50. package/dist/queue.d.ts +0 -54
  51. package/dist/render/kinds/banner.d.ts +0 -3
  52. package/dist/render/kinds/checklist.d.ts +0 -19
  53. package/dist/render/kinds/cta.d.ts +0 -8
  54. package/dist/render/kinds/modal.d.ts +0 -16
  55. package/dist/render/kinds/shared.d.ts +0 -16
  56. package/dist/render/kinds/spotlight.d.ts +0 -17
  57. package/dist/render/kinds/tooltip.d.ts +0 -3
  58. package/dist/render/root.d.ts +0 -3
  59. package/dist/render/styles.d.ts +0 -5
  60. package/dist/store.d.ts +0 -36
  61. package/dist/transport.d.ts +0 -59
  62. package/src/anchor.ts +0 -72
  63. package/src/outcome.ts +0 -51
  64. package/src/queue.ts +0 -296
  65. package/src/render/kinds/banner.ts +0 -52
  66. package/src/render/kinds/checklist.ts +0 -175
  67. package/src/render/kinds/cta.ts +0 -53
  68. package/src/render/kinds/modal.ts +0 -129
  69. package/src/render/kinds/shared.ts +0 -67
  70. package/src/render/kinds/spotlight.ts +0 -176
  71. package/src/render/kinds/tooltip.ts +0 -82
  72. package/src/render/root.ts +0 -69
  73. package/src/render/styles.ts +0 -280
  74. package/src/store.ts +0 -106
  75. package/src/transport.ts +0 -182
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseUserAgent, parseUtm, stripUrl } from './web-context'
3
+
4
+ describe('stripUrl', () => {
5
+ it('tira query e hash — href cru vira dimensão de cardinalidade quase-única', () => {
6
+ expect(stripUrl('https://app.x.com/rel/42?utm_source=x&t=9#top')).toBe(
7
+ 'https://app.x.com/rel/42',
8
+ )
9
+ expect(stripUrl('https://app.x.com/')).toBe('https://app.x.com/')
10
+ })
11
+
12
+ it('devolve null pro que não é URL', () => {
13
+ expect(stripUrl('/só-o-path')).toBeNull()
14
+ expect(stripUrl('')).toBeNull()
15
+ })
16
+ })
17
+
18
+ describe('parseUtm', () => {
19
+ it('captura os cinco parâmetros de campanha em minúsculas', () => {
20
+ const utm = parseUtm(
21
+ 'https://x.com/?utm_source=Google&utm_medium=CPC&utm_campaign=Lançamento&utm_term=abc&utm_content=v2',
22
+ )
23
+ expect(utm).toEqual({
24
+ utm_source: 'google',
25
+ utm_medium: 'cpc',
26
+ utm_campaign: 'lançamento',
27
+ utm_term: 'abc',
28
+ utm_content: 'v2',
29
+ })
30
+ })
31
+
32
+ it('ignora ausentes e vazios em vez de gravar string vazia', () => {
33
+ expect(parseUtm('https://x.com/?utm_source=&foo=1')).toEqual({})
34
+ expect(parseUtm('https://x.com/')).toEqual({})
35
+ expect(parseUtm('nem-url')).toEqual({})
36
+ })
37
+ })
38
+
39
+ describe('parseUserAgent', () => {
40
+ const CHROME_MAC =
41
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
42
+ const SAFARI_IPHONE =
43
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
44
+ const EDGE_WINDOWS =
45
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0'
46
+ const FIREFOX_LINUX = 'Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0'
47
+ const CHROME_ANDROID_TABLET =
48
+ 'Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
49
+
50
+ it('separa navegador, sistema e classe de dispositivo', () => {
51
+ expect(parseUserAgent(CHROME_MAC)).toEqual({ browser: 'Chrome', os: 'macOS', device: 'desktop' })
52
+ expect(parseUserAgent(SAFARI_IPHONE)).toEqual({ browser: 'Safari', os: 'iOS', device: 'mobile' })
53
+ expect(parseUserAgent(FIREFOX_LINUX)).toEqual({
54
+ browser: 'Firefox',
55
+ os: 'Linux',
56
+ device: 'desktop',
57
+ })
58
+ })
59
+
60
+ it('não confunde Edge com Chrome (o UA do Edge contém "chrome")', () => {
61
+ expect(parseUserAgent(EDGE_WINDOWS).browser).toBe('Edge')
62
+ })
63
+
64
+ it('android sem "mobile" é tablet', () => {
65
+ expect(parseUserAgent(CHROME_ANDROID_TABLET).device).toBe('tablet')
66
+ })
67
+
68
+ it('UA vazio não inventa campo', () => {
69
+ expect(parseUserAgent('')).toEqual({})
70
+ })
71
+ })
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Contexto de ambiente do browser · @holdyapp/sdk/web.
3
+ *
4
+ * Vai em `context` de todo evento, nunca em `properties`: properties é o
5
+ * vocabulário do produto do cliente (`workflow_key`, `plan`), context é o que o
6
+ * SDK observou sozinho. A Data layer persiste os dois (facts.context, 00247) e
7
+ * a leitura de dimensão consulta os dois.
8
+ *
9
+ * Três decisões que definem se esse dado é útil ou lixo:
10
+ * · `url` sai sem query e sem hash. Href cru tem uma variação por sessão, e
11
+ * dimensão com cardinalidade quase-única é descartada na leitura — a
12
+ * página vira ruído exatamente por excesso de precisão.
13
+ * · utm é capturado na primeira página e carregado pelo resto da sessão.
14
+ * Atribuição que só existe no primeiro evento não responde nada.
15
+ * · referrer só quando é externo. Navegação interna preenchendo "Origem do
16
+ * tráfego" com o próprio domínio esconde a origem real.
17
+ *
18
+ * Tudo aqui é best-effort: browser em modo privativo, SSR e webview sem
19
+ * storage precisam funcionar. Nenhuma função lança.
20
+ */
21
+
22
+ export type WebContext = Record<string, string>
23
+
24
+ const UTM_KEYS = [
25
+ 'utm_source',
26
+ 'utm_medium',
27
+ 'utm_campaign',
28
+ 'utm_term',
29
+ 'utm_content',
30
+ ] as const
31
+
32
+ const SESSION_STORAGE_KEY = 'holdy_session'
33
+ const ATTRIBUTION_STORAGE_KEY = 'holdy_attribution'
34
+
35
+ /* ── URL ──────────────────────────────────────────────────────────────────── */
36
+
37
+ /** `https://app.x.com/a/b?q=1#z` → `https://app.x.com/a/b`. */
38
+ export function stripUrl(href: string): string | null {
39
+ try {
40
+ const url = new URL(href)
41
+ return `${url.origin}${url.pathname}`
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /** Parâmetros de campanha presentes na URL, já em lowercase. */
48
+ export function parseUtm(href: string): WebContext {
49
+ const out: WebContext = {}
50
+ try {
51
+ const params = new URL(href).searchParams
52
+ for (const key of UTM_KEYS) {
53
+ const value = params.get(key)?.trim()
54
+ if (value) out[key] = value.toLowerCase().slice(0, 120)
55
+ }
56
+ } catch {
57
+ // href inválido — sem utm, sem drama.
58
+ }
59
+ return out
60
+ }
61
+
62
+ /* ── User agent ───────────────────────────────────────────────────────────── */
63
+
64
+ interface UaParts {
65
+ browser?: string
66
+ os?: string
67
+ device?: string
68
+ }
69
+
70
+ /**
71
+ * Fatia mínima do UA: navegador, sistema e classe de dispositivo.
72
+ *
73
+ * Não é biblioteca de UA parsing e não deve virar uma — o valor está em
74
+ * separar mobile de desktop e Safari de Chrome, não em cravar a versão.
75
+ */
76
+ export function parseUserAgent(ua: string): UaParts {
77
+ if (!ua) return {}
78
+ const s = ua.toLowerCase()
79
+
80
+ const browser = s.includes('edg/')
81
+ ? 'Edge'
82
+ : s.includes('opr/') || s.includes('opera')
83
+ ? 'Opera'
84
+ : s.includes('samsungbrowser')
85
+ ? 'Samsung Internet'
86
+ : s.includes('firefox') || s.includes('fxios')
87
+ ? 'Firefox'
88
+ : s.includes('chrome') || s.includes('crios')
89
+ ? 'Chrome'
90
+ : s.includes('safari')
91
+ ? 'Safari'
92
+ : undefined
93
+
94
+ const os = s.includes('iphone') || s.includes('ipad') || s.includes('ipod')
95
+ ? 'iOS'
96
+ : s.includes('android')
97
+ ? 'Android'
98
+ : s.includes('windows')
99
+ ? 'Windows'
100
+ : s.includes('mac os') || s.includes('macintosh')
101
+ ? 'macOS'
102
+ : s.includes('cros')
103
+ ? 'ChromeOS'
104
+ : s.includes('linux')
105
+ ? 'Linux'
106
+ : undefined
107
+
108
+ // iPad moderno se declara macOS; a pista que sobra é o suporte a toque, que
109
+ // o parser puro não vê. Tablet aqui cobre o caso declarado no UA.
110
+ const device = s.includes('ipad') || (s.includes('android') && !s.includes('mobile'))
111
+ ? 'tablet'
112
+ : s.includes('mobi') || s.includes('iphone') || s.includes('ipod')
113
+ ? 'mobile'
114
+ : 'desktop'
115
+
116
+ return {
117
+ ...(browser ? { browser } : {}),
118
+ ...(os ? { os } : {}),
119
+ device,
120
+ }
121
+ }
122
+
123
+ /* ── Storage · nunca lança ────────────────────────────────────────────────── */
124
+
125
+ function readStorage(storage: 'session' | 'local', key: string): string | null {
126
+ try {
127
+ const s = storage === 'session' ? sessionStorage : localStorage
128
+ return s.getItem(key)
129
+ } catch {
130
+ return null
131
+ }
132
+ }
133
+
134
+ function writeStorage(storage: 'session' | 'local', key: string, value: string): void {
135
+ try {
136
+ const s = storage === 'session' ? sessionStorage : localStorage
137
+ s.setItem(key, value)
138
+ } catch {
139
+ // Modo privativo / storage bloqueado — o contexto degrada, o evento segue.
140
+ }
141
+ }
142
+
143
+ function randomId(): string {
144
+ try {
145
+ return crypto.randomUUID()
146
+ } catch {
147
+ return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
148
+ }
149
+ }
150
+
151
+ /** ID da sessão de browser · vive enquanto a aba viver. */
152
+ export function resolveSessionId(): string {
153
+ const existing = readStorage('session', SESSION_STORAGE_KEY)
154
+ if (existing) return existing
155
+ const fresh = randomId()
156
+ writeStorage('session', SESSION_STORAGE_KEY, fresh)
157
+ return fresh
158
+ }
159
+
160
+ /**
161
+ * Atribuição da sessão: utm da primeira página + referrer externo de entrada.
162
+ * Gravada uma vez e repetida em todo evento da sessão.
163
+ */
164
+ export function resolveAttribution(href: string, referrer: string, currentHost: string): WebContext {
165
+ const stored = readStorage('session', ATTRIBUTION_STORAGE_KEY)
166
+ if (stored) {
167
+ try {
168
+ return JSON.parse(stored) as WebContext
169
+ } catch {
170
+ // Valor corrompido — recalcula por cima.
171
+ }
172
+ }
173
+
174
+ const attribution: WebContext = { ...parseUtm(href) }
175
+
176
+ const referrerHost = hostOf(referrer)
177
+ // Referrer interno não é origem de tráfego, é navegação.
178
+ if (referrerHost && referrerHost !== currentHost) {
179
+ const clean = stripUrl(referrer)
180
+ if (clean) attribution.referrer = clean
181
+ attribution.referrer_host = referrerHost
182
+ }
183
+
184
+ writeStorage('session', ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution))
185
+ return attribution
186
+ }
187
+
188
+ function hostOf(href: string): string | null {
189
+ try {
190
+ return new URL(href).hostname.toLowerCase() || null
191
+ } catch {
192
+ return null
193
+ }
194
+ }
195
+
196
+ /* ── Montagem ─────────────────────────────────────────────────────────────── */
197
+
198
+ /**
199
+ * Contexto completo do evento atual. Fora do browser devolve `{}` — o SDK
200
+ * server tem o próprio caminho e não deve inventar ambiente.
201
+ */
202
+ export function buildWebContext(): WebContext {
203
+ if (typeof window === 'undefined' || typeof location === 'undefined') return {}
204
+
205
+ const href = location.href
206
+ const referrer = typeof document !== 'undefined' ? document.referrer : ''
207
+ const host = location.hostname.toLowerCase()
208
+
209
+ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
210
+ const { browser, os, device } = parseUserAgent(ua)
211
+
212
+ const url = stripUrl(href)
213
+
214
+ return {
215
+ ...(url ? { url } : {}),
216
+ path: location.pathname || '/',
217
+ host,
218
+ ...(browser ? { browser } : {}),
219
+ ...(os ? { os } : {}),
220
+ ...(device ? { device } : {}),
221
+ ...(typeof navigator !== 'undefined' && navigator.language
222
+ ? { locale: navigator.language }
223
+ : {}),
224
+ session_id: resolveSessionId(),
225
+ ...resolveAttribution(href, referrer, host),
226
+ }
227
+ }
package/src/web.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @holdyapp/sdk/web · browser (source: sdk_web).
3
+ *
4
+ * anonymous_id gerado e persistido em localStorage (holdy_anon_id) — enviado em
5
+ * todo event até identify() promover pra user_id (identity graph faz o merge
6
+ * downstream, nunca o SDK). Flush no pagehide via fetch keepalive.
7
+ *
8
+ * Todo evento carrega o contexto de ambiente (url, path, referrer externo,
9
+ * utm da sessão, browser, os, device, locale, session_id) em `context` —
10
+ * separado das properties do produto. Pageview é automático por default:
11
+ * carregamento inicial e cada mudança de rota, inclusive em SPA.
12
+ */
13
+
14
+ import { HoldyCore } from './core'
15
+ import { installAutoCapture, type NavigationKind } from './auto-capture'
16
+ import { buildWebContext } from './web-context'
17
+ import type { HoldyOptions, Properties } from './types'
18
+
19
+ const ANON_STORAGE_KEY = 'holdy_anon_id'
20
+
21
+ export interface HoldyWebOptions extends HoldyOptions {
22
+ /**
23
+ * Emitir `$pageview` sozinho no load e a cada mudança de rota · default true.
24
+ * Desligue só se o produto já chama `page()` na mão em cada tela.
25
+ */
26
+ autoCapture?: boolean
27
+ }
28
+
29
+ export class HoldyWeb extends HoldyCore {
30
+ private teardownAutoCapture: (() => void) | null = null
31
+
32
+ constructor(opts: HoldyWebOptions) {
33
+ super('sdk_web', opts)
34
+ this.anonymousId = resolveAnonymousId()
35
+
36
+ if (typeof window !== 'undefined') {
37
+ window.addEventListener('pagehide', () => void this.flush())
38
+ document.addEventListener('visibilitychange', () => {
39
+ if (document.visibilityState === 'hidden') void this.flush()
40
+ })
41
+ }
42
+
43
+ if (opts.autoCapture !== false) {
44
+ this.teardownAutoCapture = installAutoCapture({
45
+ onNavigate: (kind: NavigationKind) => this.page(undefined, { navigation: kind }),
46
+ })
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Pageview. O `$` marca evento emitido pelo SDK, não pelo produto (mesma
52
+ * convenção de `$identify`/`$group`); a canonicalização downstream remove o
53
+ * sigilo, então `$pageview` e `pageview` são o mesmo evento na leitura.
54
+ * url/path/referrer/utm já vêm no context de todo evento — aqui só entra o
55
+ * que é da página como conteúdo (nome e título).
56
+ */
57
+ page(name?: string, properties?: Properties): void {
58
+ this.track('$pageview', {
59
+ ...(properties ?? {}),
60
+ ...(name ? { page_name: name } : {}),
61
+ ...(typeof document !== 'undefined' && document.title ? { title: document.title } : {}),
62
+ })
63
+ }
64
+
65
+ reset(): void {
66
+ this.userId = null
67
+ this.accountKey = null
68
+ try {
69
+ localStorage.removeItem(ANON_STORAGE_KEY)
70
+ } catch {
71
+ // storage indisponível (SSR/privacy mode) — ok
72
+ }
73
+ this.anonymousId = resolveAnonymousId()
74
+ }
75
+
76
+ /** Flush final + desinstala o auto-capture (devolve `history` ao original). */
77
+ override async shutdown(): Promise<void> {
78
+ this.teardownAutoCapture?.()
79
+ this.teardownAutoCapture = null
80
+ await super.shutdown()
81
+ }
82
+
83
+ protected override environmentContext(): Properties {
84
+ return buildWebContext()
85
+ }
86
+ }
87
+
88
+ function resolveAnonymousId(): string {
89
+ try {
90
+ const existing = localStorage.getItem(ANON_STORAGE_KEY)
91
+ if (existing) return existing
92
+ const fresh = crypto.randomUUID()
93
+ localStorage.setItem(ANON_STORAGE_KEY, fresh)
94
+ return fresh
95
+ } catch {
96
+ return crypto.randomUUID()
97
+ }
98
+ }
99
+
100
+ let singleton: HoldyWeb | null = null
101
+
102
+ /** Init idempotente — segunda chamada retorna a instância existente. */
103
+ export function initHoldy(opts: HoldyWebOptions): HoldyWeb {
104
+ if (!singleton) singleton = new HoldyWeb(opts)
105
+ return singleton
106
+ }
107
+
108
+ export type { HoldyOptions, Properties }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Holdy
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
package/dist/anchor.d.ts DELETED
@@ -1,8 +0,0 @@
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
- export declare function resolveAnchor(selector: string | undefined): Promise<Element | null>;