@hanzo/event 0.3.43 → 0.3.48

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.
@@ -0,0 +1,205 @@
1
+ // The tag manager: nothing loads before consent, everything loads after, and an
2
+ // event reaches each platform under one id and the names the platform table gives.
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ const g = globalThis as Record<string, unknown>
7
+ const original = {
8
+ navigator: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
9
+ Intl: Object.getOwnPropertyDescriptor(globalThis, 'Intl'),
10
+ }
11
+ const put = (k: string, value: unknown) => Object.defineProperty(globalThis, k, { value, configurable: true, writable: true })
12
+
13
+ const TAGS = {
14
+ tags: [
15
+ { platform: 'ga4', type: 'ga', id: 'G-TEST' },
16
+ { platform: 'google-ads', type: 'gads', id: 'AW-1', events: { order_completed: 'AW-1/abc' } },
17
+ { platform: 'meta', type: 'meta', id: '111' },
18
+ { platform: 'linkedin', type: 'linkedin', id: '555', events: { order_completed: '9876' } },
19
+ { platform: 'x', type: 'x', id: 'o1abc', events: { order_completed: 'tw-o1abc-buy' } },
20
+ { platform: 'tiktok', type: 'tiktok', id: 'TT1' },
21
+ ],
22
+ }
23
+
24
+ function browser(zone: string, jar = new Map<string, string>()) {
25
+ const scripts: string[] = []
26
+ const listeners = new Map<string, Array<() => void>>()
27
+ const requests: string[] = []
28
+ put('navigator', { globalPrivacyControl: false, userAgent: 'UA' })
29
+ put('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: zone }) }) })
30
+ g.Event = class {
31
+ constructor(public type: string) {}
32
+ }
33
+ g.window = {
34
+ location: { hostname: 'hanzo.ai', protocol: 'https:', search: '', href: 'https://hanzo.ai/' },
35
+ dispatchEvent: (e: { type: string }) => listeners.get(e.type)?.forEach((f) => f()),
36
+ addEventListener: (t: string, f: () => void) => listeners.set(t, [...(listeners.get(t) ?? []), f]),
37
+ removeEventListener: () => undefined,
38
+ }
39
+ g.addEventListener = (g.window as { addEventListener: unknown }).addEventListener
40
+ g.removeEventListener = () => undefined
41
+ g.document = {
42
+ get cookie() {
43
+ return [...jar].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ')
44
+ },
45
+ set cookie(line: string) {
46
+ const [pair] = line.split(';')
47
+ const i = pair.indexOf('=')
48
+ jar.set(pair.slice(0, i), decodeURIComponent(pair.slice(i + 1)))
49
+ },
50
+ createElement: () => ({}) as Record<string, unknown>,
51
+ head: { appendChild: (s: { src: string; onload?: () => void }) => (scripts.push(s.src), setTimeout(() => s.onload?.(), 0)) },
52
+ }
53
+ put('fetch', async (url: string) => {
54
+ requests.push(url)
55
+ return { ok: true, json: async () => TAGS }
56
+ })
57
+ let n = 0
58
+ put('crypto', { randomUUID: () => `id-${++n}` })
59
+ return { jar, scripts, requests }
60
+ }
61
+
62
+ beforeEach(() => vi.resetModules())
63
+ afterEach(() => {
64
+ for (const k of ['window', 'document', 'Event', 'addEventListener', 'removeEventListener', 'fetch', 'crypto']) delete g[k]
65
+ for (const [k, d] of Object.entries(original)) if (d) Object.defineProperty(globalThis, k, d)
66
+ })
67
+
68
+ const tick = () => new Promise((r) => setTimeout(r, 10))
69
+
70
+ describe('before consent', () => {
71
+ it('an EU visitor makes no request to any platform', async () => {
72
+ const b = browser('Europe/Berlin')
73
+ const { startTags } = await import('./index')
74
+ startTags({ key: 'pk-x' })
75
+ await tick()
76
+ expect(b.requests).toHaveLength(1)
77
+ expect(b.requests[0]).toContain('/v1/project/tags')
78
+ expect(b.scripts).toEqual([])
79
+ })
80
+
81
+ it('a US visitor is presumed to allow, and every tag loads', async () => {
82
+ const b = browser('America/New_York')
83
+ const { startTags } = await import('./index')
84
+ startTags({ key: 'pk-x' })
85
+ await tick()
86
+ expect(b.scripts.map((s) => new URL(s).hostname).sort()).toEqual(
87
+ ['analytics.tiktok.com', 'connect.facebook.net', 'snap.licdn.com', 'static.ads-twitter.com', 'www.googletagmanager.com'].sort(),
88
+ )
89
+ })
90
+ })
91
+
92
+ describe('after accepting', () => {
93
+ it('loads Google, Meta, LinkedIn, X and TikTok with no reload', async () => {
94
+ const b = browser('Europe/Berlin')
95
+ const { startTags, acceptAll } = await import('./index')
96
+ startTags({ key: 'pk-x' })
97
+ await tick()
98
+ acceptAll()
99
+ await tick()
100
+ expect(b.scripts).toHaveLength(5)
101
+ })
102
+
103
+ it('sets Consent Mode v2 denied before any Google script, then updates it', async () => {
104
+ const b = browser('Europe/Berlin')
105
+ const { startTags, acceptAll } = await import('./index')
106
+ startTags({ key: 'pk-x' })
107
+ await tick()
108
+ expect(b.scripts).toEqual([])
109
+ acceptAll()
110
+ await tick()
111
+ const layer = (g.window as { dataLayer: IArguments[] }).dataLayer.map((a) => Array.from(a))
112
+ const dflt = layer.findIndex((a) => a[0] === 'consent' && a[1] === 'default')
113
+ const js = layer.findIndex((a) => a[0] === 'js')
114
+ expect(dflt).toBeGreaterThanOrEqual(0)
115
+ expect(dflt).toBeLessThan(js)
116
+ expect(layer[dflt][2]).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' })
117
+ const update = layer.find((a) => a[0] === 'consent' && a[1] === 'update')
118
+ expect(update?.[2]).toMatchObject({ analytics_storage: 'granted', ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted' })
119
+ })
120
+
121
+ it('analytics alone loads only Google, cookieless for ads', async () => {
122
+ const b = browser('Europe/Berlin')
123
+ const { startTags, saveConsent } = await import('./index')
124
+ startTags({ key: 'pk-x' })
125
+ await tick()
126
+ saveConsent({ analytics: true, marketing: false, ads: false })
127
+ await tick()
128
+ expect(b.scripts.map((s) => new URL(s).hostname)).toEqual(['www.googletagmanager.com'])
129
+ })
130
+ })
131
+
132
+ describe('one event, every tag', () => {
133
+ it('sends purchase under one id to each platform, with the names the table gives', async () => {
134
+ browser('America/New_York')
135
+ const { startTags, track } = await import('./index')
136
+ const captured: Array<{ name: string; props: Record<string, unknown> }> = []
137
+ startTags({ key: 'pk-x' })
138
+ await tick()
139
+ const w = g.window as unknown as Record<string, unknown>
140
+ const gtag: unknown[][] = []
141
+ const fbq: unknown[][] = []
142
+ const ttq: unknown[][] = []
143
+ const twq: unknown[][] = []
144
+ const lintrk: unknown[][] = []
145
+ w.gtag = (...a: unknown[]) => gtag.push(a)
146
+ w.fbq = (...a: unknown[]) => fbq.push(a)
147
+ w.ttq = { track: (...a: unknown[]) => ttq.push(a) }
148
+ w.twq = (...a: unknown[]) => twq.push(a)
149
+ w.lintrk = (...a: unknown[]) => lintrk.push(a)
150
+ const items = [{ item_id: 'dev', item_name: 'Dev', price: 19, quantity: 1 }]
151
+ track({ capture: (name: string, props: Record<string, unknown>) => captured.push({ name, props }) } as never, 'order_completed', {
152
+ order_id: 'ord-1', value: 19, currency: 'USD', items,
153
+ })
154
+ await tick()
155
+
156
+ expect(captured).toHaveLength(1)
157
+ const id = captured[0].props.event_id
158
+ expect(id).toBe('id-1')
159
+ expect(String(captured[0].props.tags).split(',').sort()).toEqual(['ga', 'gads', 'linkedin', 'meta', 'tiktok', 'x'])
160
+
161
+ const ga = gtag.find((a) => a[0] === 'event' && a[1] === 'purchase')
162
+ expect(ga?.[2]).toMatchObject({ transaction_id: 'ord-1', event_id: id, items, value: 19 })
163
+ expect(gtag.find((a) => a[1] === 'conversion')?.[2]).toMatchObject({ send_to: 'AW-1/abc', value: 19 })
164
+ expect(fbq.map((a) => [a[0], a[1], (a[3] as { eventID: string }).eventID])).toEqual([
165
+ ['track', 'Purchase', id],
166
+ ['track', 'Subscribe', id],
167
+ ])
168
+ expect(ttq[0][0]).toBe('CompletePayment')
169
+ expect(ttq[0][2]).toEqual({ event_id: id })
170
+ expect(twq[0][1]).toBe('tw-o1abc-buy')
171
+ expect(lintrk[0]).toEqual(['track', { conversion_id: 9876 }])
172
+ })
173
+
174
+ it('a test order is debug traffic', async () => {
175
+ browser('America/New_York')
176
+ const { startTags, mirror } = await import('./index')
177
+ startTags({ key: 'pk-x' })
178
+ await tick()
179
+ const w = g.window as unknown as Record<string, unknown>
180
+ const gtag: unknown[][] = []
181
+ w.gtag = (...a: unknown[]) => gtag.push(a)
182
+ mirror('order_completed', { test: true, order_id: 'o', value: 19, items: [{ item_id: 'dev', price: 19 }] }, 'id-9', 'ga')
183
+ const sent = gtag.find((a) => a[1] === 'purchase')?.[2] as { value: number; items: Array<{ price: number }> }
184
+ expect(sent).toMatchObject({ debug_mode: true, value: 0 })
185
+ expect(sent.items[0].price).toBe(0)
186
+ })
187
+ })
188
+
189
+ describe('a paid order the server also states', () => {
190
+ it('fires the pixels under the order id and does not touch our stream', async () => {
191
+ browser('America/New_York')
192
+ const { startTags, track } = await import('./index')
193
+ startTags({ key: 'pk-x' })
194
+ await tick()
195
+ const w = g.window as unknown as Record<string, unknown>
196
+ const fbq: unknown[][] = []
197
+ const captured: unknown[] = []
198
+ w.gtag = () => undefined
199
+ w.fbq = (...a: unknown[]) => fbq.push(a)
200
+ track({ capture: (...a: unknown[]) => captured.push(a) } as never, 'order_completed', { event_id: 'sub_1', order_id: 'sub_1', value: 19 }, 'tags')
201
+ await tick()
202
+ expect(captured).toEqual([])
203
+ expect(fbq.map((a) => (a[3] as { eventID: string }).eventID)).toEqual(['sub_1', 'sub_1'])
204
+ })
205
+ })
package/src/tags.ts ADDED
@@ -0,0 +1,482 @@
1
+ // The tag manager: loads each ad and analytics platform's browser pixel, after
2
+ // consent, from the site's tag configuration, and fires every event to each one
3
+ // under one event_id.
4
+ //
5
+ // WHERE THE IDS COME FROM. Never from a site's code. The site's tag set is
6
+ // cloud's `GET /v1/project/tags` (non-secret ids only), so connecting a
7
+ // platform to a site is a configuration change and every surface gets it:
8
+ //
9
+ // { tags: [{ platform: 'ga4', type: 'ga', id: 'G-…' },
10
+ // { platform: 'linkedin', type: 'linkedin', id: '1234',
11
+ // events: { order_completed: '9876' } }, …] }
12
+ //
13
+ // `events` maps one of OUR event names to the platform's own conversion id for
14
+ // it: a Google Ads `AW-…/label`, a LinkedIn conversion rule, an X event id.
15
+ // Google Ads, LinkedIn and X name conversions by rule, not by event, and an
16
+ // event with no rule there is not sent to that platform.
17
+ //
18
+ // WHAT LOADS WHEN. Nothing loads before consent. Analytics allows GA4;
19
+ // Marketing allows Google Ads, Meta, LinkedIn, X and TikTok. A visitor who has
20
+ // not chosen has allowed what their region presumes (consent.ts), so an EU
21
+ // visitor's page makes no request to any platform until they accept. Google's
22
+ // Consent Mode v2 is set denied before gtag.js is fetched and updated on every
23
+ // choice. A choice that allows more loads the rest with no reload.
24
+ //
25
+ // ONE EVENT ID. `track` mints it, fires the browser pixels with it, and records
26
+ // it on our stream with the list of pixels that fired (`tags`). Cloud forwards
27
+ // the same moment server-side under the same id, and each platform keeps one
28
+ // (Meta and TikTok by event_id, GA4 by transaction_id on a purchase); it sends
29
+ // GA4 only what the page's own gtag did not, which is what `tags` says.
30
+ //
31
+ // WHAT EACH PLATFORM CALLS AN EVENT is the one table in @hanzo/events; cloud
32
+ // reads the same table.
33
+
34
+ import { namesOn } from '@hanzo/events'
35
+ import { CONSENT_EVENT, read, render, type Choice } from './consent'
36
+ import { capture, touch } from './touch'
37
+ import type { Analytics } from './core'
38
+
39
+ export interface BrowserTag {
40
+ platform: string
41
+ type: string
42
+ id: string
43
+ events?: Record<string, string>
44
+ }
45
+
46
+ export interface TagOptions {
47
+ /** The site's publishable key; resolves its tag set. */
48
+ key?: string
49
+ /** The site's host, for a site whose key is the org's. */
50
+ host?: string
51
+ /** cloud's origin; defaults to https://api.hanzo.ai. */
52
+ base?: string
53
+ /** Domains one visit crosses, so GA4 keeps it one session. */
54
+ domains?: string[]
55
+ }
56
+
57
+ /**
58
+ * The publishable key of each site's project, which is how cloud finds the site's
59
+ * tag set. A `pk-` is public by design (it ships in every page); it names a site,
60
+ * never a platform id. A host absent here has no tag config unless `start` is
61
+ * given a key.
62
+ */
63
+ export const SITE_KEY: Readonly<Record<string, string>> = Object.freeze({
64
+ 'hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
65
+ 'www.hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
66
+ 'hanzo.app': 'pk-wlnXN2a9_vmCm60yTFtQ629Q8TyuaxBNZbY1RWT72gQ',
67
+ 'hanzo.team': 'pk-NCzD2FiHpZv8KUpkCX4olT1LJOJsMxBC_Z8NkiQsOFQ',
68
+ 'pay.hanzo.ai': 'pk-eX6kv7JZNoiYn1WkeJH3tT_8OvkVYScmCXnLMwxTKf8',
69
+ 'platform.hanzo.ai': 'pk-My1RpZLEUnTj8vAdPbWKYdDUuhxZJ4dVjHcwjZN4rZ8',
70
+ 'docs.hanzo.ai': 'pk-jukhtjMT2ymoeBDAeFjINQWlBv-v9sNn1TPztiCrrwk',
71
+ 'hanzo.bot': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
72
+ 'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
73
+ })
74
+
75
+ type Call = (...args: unknown[]) => void
76
+ type Page = {
77
+ dataLayer?: unknown[]
78
+ gtag?: Call
79
+ fbq?: Call & { queue?: unknown[]; callMethod?: Call; push?: unknown; loaded?: boolean; version?: string }
80
+ _fbq?: unknown
81
+ lintrk?: Call & { q?: unknown[] }
82
+ _linkedin_data_partner_ids?: string[]
83
+ twq?: Call & { queue?: unknown[]; exe?: Call; version?: string }
84
+ ttq?: Record<string, Call> & { _i?: Record<string, unknown>; _t?: Record<string, number>; _o?: Record<string, unknown>; methods?: string[]; load?: Call; page?: Call; track?: Call }
85
+ TiktokAnalyticsObject?: string
86
+ }
87
+
88
+ const page = (): Page => window as unknown as Page
89
+
90
+ let tags: BrowserTag[] = []
91
+ let configured = false
92
+ let options: TagOptions = {}
93
+ const loaded = new Set<string>()
94
+
95
+ /** gtag.js: 'idle' until fetched, 'wait' while on its way, then how it answered. */
96
+ let google: 'idle' | 'wait' | 'loaded' | 'failed' = 'idle'
97
+ let configAnswered = false
98
+
99
+ const held: Array<() => void> = []
100
+ let leaving = false
101
+
102
+ function settled(): boolean {
103
+ return configAnswered && google !== 'wait'
104
+ }
105
+
106
+ function flush(): void {
107
+ if (settled() || leaving) held.splice(0).forEach((send) => send())
108
+ }
109
+
110
+ function script(src: string, onload?: () => void, onerror?: () => void): void {
111
+ const s = document.createElement('script')
112
+ s.async = true
113
+ s.src = src
114
+ if (onload) s.onload = onload
115
+ if (onerror) s.onerror = onerror
116
+ document.head.appendChild(s)
117
+ }
118
+
119
+ // ── Google ──────────────────────────────────────────────────────────────
120
+
121
+ function gtagStub(): Call {
122
+ const p = page()
123
+ p.dataLayer = p.dataLayer || []
124
+ if (!p.gtag) {
125
+ // gtag.js reads the `arguments` object itself, so this is a function and not an arrow.
126
+ p.gtag = function () {
127
+ // eslint-disable-next-line prefer-rest-params
128
+ p.dataLayer!.push(arguments)
129
+ }
130
+ }
131
+ return p.gtag
132
+ }
133
+
134
+ const g = (on: boolean) => (on ? 'granted' : 'denied')
135
+
136
+ /** Google Consent Mode v2: denied until said otherwise, then the visitor's choice. */
137
+ function consentMode(c: Choice, first: boolean): void {
138
+ const gtag = gtagStub()
139
+ const state = {
140
+ analytics_storage: g(c.analytics),
141
+ ad_storage: g(c.marketing),
142
+ ad_user_data: g(c.marketing),
143
+ ad_personalization: g(c.marketing && c.ads),
144
+ }
145
+ if (first) {
146
+ gtag('consent', 'default', {
147
+ analytics_storage: 'denied',
148
+ ad_storage: 'denied',
149
+ ad_user_data: 'denied',
150
+ ad_personalization: 'denied',
151
+ wait_for_update: 500,
152
+ })
153
+ }
154
+ gtag('consent', 'update', state)
155
+ }
156
+
157
+ function loadGoogle(ids: string[]): void {
158
+ const gtag = gtagStub()
159
+ const fresh = ids.filter((id) => !loaded.has(id))
160
+ if (!fresh.length) return
161
+ if (google === 'idle') {
162
+ google = 'wait'
163
+ gtag('js', new Date())
164
+ const done = (to: 'loaded' | 'failed') => () => {
165
+ if (google !== 'wait') return
166
+ google = to
167
+ flush()
168
+ }
169
+ script(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(fresh[0])}`, done('loaded'), done('failed'))
170
+ // gtag.js answers once: loaded, failed (a blocker), or silent past 8 s.
171
+ setTimeout(done('failed'), 8000)
172
+ }
173
+ for (const id of fresh) {
174
+ loaded.add(id)
175
+ gtag('config', id, options.domains ? { linker: { domains: options.domains } } : {})
176
+ }
177
+ }
178
+
179
+ // ── The other pixels ────────────────────────────────────────────────────
180
+
181
+ function loadMeta(id: string, c: Choice): void {
182
+ const p = page()
183
+ if (!p.fbq) {
184
+ const n = (p.fbq = function (...a: unknown[]) {
185
+ if (n.callMethod) n.callMethod(...a)
186
+ else n.queue!.push(a)
187
+ } as NonNullable<Page['fbq']>)
188
+ if (!p._fbq) p._fbq = n
189
+ n.push = n
190
+ n.loaded = true
191
+ n.version = '2.0'
192
+ n.queue = []
193
+ script('https://connect.facebook.net/en_US/fbevents.js')
194
+ }
195
+ if (!c.ads) p.fbq('dataProcessingOptions', ['LDU'], 0, 0)
196
+ p.fbq('init', id)
197
+ p.fbq('track', 'PageView')
198
+ }
199
+
200
+ function loadLinkedIn(id: string): void {
201
+ const p = page()
202
+ p._linkedin_data_partner_ids = p._linkedin_data_partner_ids || []
203
+ p._linkedin_data_partner_ids.push(id)
204
+ if (!p.lintrk) {
205
+ const l = (p.lintrk = function (...a: unknown[]) {
206
+ l.q!.push(a)
207
+ } as NonNullable<Page['lintrk']>)
208
+ l.q = []
209
+ }
210
+ script('https://snap.licdn.com/li.lms-analytics/insight.min.js')
211
+ }
212
+
213
+ function loadX(id: string): void {
214
+ const p = page()
215
+ if (!p.twq) {
216
+ const s = (p.twq = function (...a: unknown[]) {
217
+ if (s.exe) s.exe(...a)
218
+ else s.queue!.push(a)
219
+ } as NonNullable<Page['twq']>)
220
+ s.version = '1.1'
221
+ s.queue = []
222
+ script('https://static.ads-twitter.com/uwt.js')
223
+ }
224
+ p.twq('config', id)
225
+ }
226
+
227
+ const TIKTOK_METHODS = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie']
228
+
229
+ function loadTikTok(id: string): void {
230
+ const p = page()
231
+ p.TiktokAnalyticsObject = 'ttq'
232
+ const q = (p.ttq = p.ttq || ([] as unknown as NonNullable<Page['ttq']>)) as unknown as Record<string, unknown> & unknown[]
233
+ if (!q._i) {
234
+ q.methods = TIKTOK_METHODS
235
+ for (const m of TIKTOK_METHODS) q[m] = (...a: unknown[]) => q.push([m, ...a])
236
+ q._i = {}
237
+ q._t = {}
238
+ q._o = {}
239
+ q.load = (sdk: string) => {
240
+ ;(q._i as Record<string, unknown>)[sdk] = []
241
+ ;(q._t as Record<string, number>)[sdk] = Date.now()
242
+ script(`https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(sdk)}&lib=ttq`)
243
+ }
244
+ }
245
+ ;(q.load as Call)(id)
246
+ ;(q.page as Call)()
247
+ }
248
+
249
+ // ── Which tag is allowed and running ────────────────────────────────────
250
+
251
+ const NEEDS: Record<string, keyof Choice> = {
252
+ ga: 'analytics',
253
+ gads: 'marketing',
254
+ meta: 'marketing',
255
+ linkedin: 'marketing',
256
+ x: 'marketing',
257
+ tiktok: 'marketing',
258
+ }
259
+
260
+ /** GA4 also counts a visit when only Marketing is allowed (Consent Mode keeps it cookieless). */
261
+ const allowed = (t: BrowserTag, c: Choice): boolean =>
262
+ t.type === 'ga' ? c.analytics || c.marketing : Boolean(NEEDS[t.type] && c[NEEDS[t.type]])
263
+
264
+ function apply(): void {
265
+ if (typeof window === 'undefined' || !configured) return
266
+ const c = read()
267
+ capture(c)
268
+ const on = tags.filter((t) => allowed(t, c))
269
+ const ids = on.filter((t) => t.type === 'ga' || t.type === 'gads').map((t) => t.id)
270
+ if (ids.length) {
271
+ consentMode(c, !loaded.has('consent'))
272
+ loaded.add('consent')
273
+ loadGoogle(ids)
274
+ }
275
+ for (const t of on) {
276
+ const k = `${t.type}:${t.id}`
277
+ if (loaded.has(k)) continue
278
+ if (t.type === 'meta') loadMeta(t.id, c)
279
+ else if (t.type === 'linkedin') loadLinkedIn(t.id)
280
+ else if (t.type === 'x') loadX(t.id)
281
+ else if (t.type === 'tiktok') loadTikTok(t.id)
282
+ else continue
283
+ loaded.add(k)
284
+ }
285
+ flush()
286
+ }
287
+
288
+ /**
289
+ * Starts the tag manager: fetches the site's tag set and loads what consent
290
+ * allows, again on every consent change. Safe to call on every page load, and
291
+ * a no-op on the server. Returns a function that stops listening.
292
+ */
293
+ export function start(o: TagOptions = {}): () => void {
294
+ if (typeof window === 'undefined') return () => undefined
295
+ options = o
296
+ addEventListener(CONSENT_EVENT, apply)
297
+ addEventListener('pagehide', pagehide)
298
+ if (!configured) {
299
+ configured = true
300
+ const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
301
+ const q = new URLSearchParams()
302
+ const host = o.host ?? window.location.hostname
303
+ const key = o.key ?? SITE_KEY[host]
304
+ if (key) q.set('key', key)
305
+ q.set('host', host)
306
+ const answer = (list: BrowserTag[]) => {
307
+ tags = list
308
+ configAnswered = true
309
+ apply()
310
+ }
311
+ // A page never waits on its tag config: an unreachable cloud is an empty set.
312
+ const cap = setTimeout(() => answer([]), 3000)
313
+ fetch(`${base}/v1/project/tags?${q}`)
314
+ .then((r) => (r.ok ? r.json() : { tags: [] }))
315
+ .then((j: { tags?: BrowserTag[] }) => {
316
+ clearTimeout(cap)
317
+ answer(Array.isArray(j.tags) ? j.tags : [])
318
+ })
319
+ .catch(() => {
320
+ clearTimeout(cap)
321
+ answer([])
322
+ })
323
+ }
324
+ return () => {
325
+ removeEventListener(CONSENT_EVENT, apply)
326
+ removeEventListener('pagehide', pagehide)
327
+ }
328
+ }
329
+
330
+ function pagehide(): void {
331
+ leaving = true
332
+ flush()
333
+ }
334
+
335
+ /** The browser tags a moment can reach right now, as the `tags` property spells it. */
336
+ export function reach(): string {
337
+ const c = read()
338
+ const to: string[] = []
339
+ for (const t of tags) {
340
+ if (!allowed(t, c)) continue
341
+ const running =
342
+ t.type === 'ga'
343
+ ? google === 'loaded' && c.analytics
344
+ : t.type === 'gads'
345
+ ? google === 'loaded' && c.marketing
346
+ : loaded.has(`${t.type}:${t.id}`)
347
+ if (running && !to.includes(t.type)) to.push(t.type)
348
+ }
349
+ return to.join(',')
350
+ }
351
+
352
+ // ── Firing an event ─────────────────────────────────────────────────────
353
+
354
+ interface Item {
355
+ item_id: string
356
+ item_name?: string
357
+ item_category?: string
358
+ item_variant?: string
359
+ price?: number
360
+ quantity?: number
361
+ }
362
+
363
+ const META_STANDARD = new Set([
364
+ 'AddPaymentInfo', 'AddToCart', 'AddToWishlist', 'CompleteRegistration', 'Contact', 'CustomizeProduct',
365
+ 'Donate', 'FindLocation', 'InitiateCheckout', 'Lead', 'Purchase', 'Schedule', 'Search', 'StartTrial',
366
+ 'SubmitApplication', 'Subscribe', 'ViewContent',
367
+ ])
368
+
369
+ function shape(platform: 'meta' | 'tiktok' | 'x', p: Record<string, unknown>): Record<string, unknown> {
370
+ const items = Array.isArray(p.items) ? (p.items as Item[]) : []
371
+ const out: Record<string, unknown> = {}
372
+ if (typeof p.value === 'number') out.value = p.value
373
+ out.currency = typeof p.currency === 'string' ? p.currency : 'USD'
374
+ if (!items.length) return out
375
+ if (platform === 'meta') {
376
+ out.content_type = 'product'
377
+ out.content_ids = items.map((i) => i.item_id)
378
+ out.contents = items.map((i) => ({ id: i.item_id, quantity: i.quantity ?? 1, item_price: i.price }))
379
+ out.content_name = items[0].item_name
380
+ out.num_items = items.reduce((n, i) => n + (i.quantity ?? 1), 0)
381
+ } else if (platform === 'tiktok') {
382
+ out.content_type = 'product'
383
+ out.contents = items.map((i) => ({ content_id: i.item_id, content_name: i.item_name, quantity: i.quantity ?? 1, price: i.price }))
384
+ } else {
385
+ out.contents = items.map((i) => ({ content_id: i.item_id, content_name: i.item_name, content_price: i.price, num_items: i.quantity ?? 1 }))
386
+ }
387
+ return out
388
+ }
389
+
390
+ /** A test order states no amount: nothing sent for it can be summed into revenue. */
391
+ function unpriced(p: Record<string, unknown>): Record<string, unknown> {
392
+ const out: Record<string, unknown> = { ...p, value: 0 }
393
+ if (Array.isArray(p.items)) out.items = (p.items as Item[]).map((i) => ({ ...i, price: 0 }))
394
+ return out
395
+ }
396
+
397
+ /**
398
+ * Sends one moment to the browser tags named in `to`, under `id`. The names each
399
+ * platform knows it by are the table in @hanzo/events; a platform the table
400
+ * gives no name, or a rule-named platform with no rule for it, is not sent it.
401
+ */
402
+ export function mirror(name: string, params: Record<string, unknown>, id: string, to = reach()): void {
403
+ const p = page()
404
+ const on = new Set(to.split(',').filter(Boolean))
405
+ const ga4 = namesOn(name, 'ga4')
406
+ const test = params.test === true
407
+ if (test) params = unpriced(params)
408
+ const transaction = typeof params.order_id === 'string' ? { transaction_id: params.order_id } : {}
409
+ for (const t of tags) {
410
+ if (!on.has(t.type)) continue
411
+ const rule = t.events?.[name]
412
+ if (t.type === 'ga') {
413
+ for (const n of ga4) {
414
+ p.gtag?.('event', n, { ...params, ...transaction, event_id: id, transport_type: 'beacon', ...(test ? { debug_mode: true } : {}) })
415
+ }
416
+ } else if (t.type === 'gads' && rule) {
417
+ p.gtag?.('event', 'conversion', {
418
+ send_to: rule,
419
+ value: typeof params.value === 'number' ? params.value : undefined,
420
+ currency: typeof params.currency === 'string' ? params.currency : 'USD',
421
+ ...transaction,
422
+ })
423
+ } else if (t.type === 'meta') {
424
+ for (const n of namesOn(name, 'meta')) {
425
+ p.fbq?.(META_STANDARD.has(n) ? 'track' : 'trackCustom', n, shape('meta', params), { eventID: id })
426
+ }
427
+ } else if (t.type === 'tiktok') {
428
+ for (const n of namesOn(name, 'tiktok')) p.ttq?.track?.(n, shape('tiktok', params), { event_id: id })
429
+ } else if (t.type === 'linkedin' && rule && namesOn(name, 'linkedin').length) {
430
+ p.lintrk?.('track', { conversion_id: Number(rule) })
431
+ } else if (t.type === 'x' && rule && namesOn(name, 'x').length) {
432
+ p.twq?.('event', rule, { ...shape('x', params), conversion_id: id })
433
+ }
434
+ }
435
+ }
436
+
437
+ /**
438
+ * One moment, to every place it is counted, under one event_id: our stream
439
+ * always hears it (with the consent, the click and the browser ids cloud
440
+ * forwards on), and each browser tag that is running hears it too. A moment
441
+ * that arrives while gtag.js is still on its way waits for it, so GA4 sees it
442
+ * with a session; a page that is leaving sends what it holds as things stand.
443
+ *
444
+ * A paid order is stated by the SERVER on our stream (and forwarded server-side);
445
+ * its browser copy is `track(stream, 'order_completed', {event_id: order, order_id:
446
+ * order, …}, 'tags')`: the same id, the pixels only, so nothing counts twice.
447
+ */
448
+ export function track(
449
+ stream: Analytics | undefined,
450
+ name: string,
451
+ params: Record<string, unknown> = {},
452
+ /** `'tags'` fires the browser pixels only: the server states this moment on our stream itself. */
453
+ only?: 'tags',
454
+ ): void {
455
+ if (typeof window === 'undefined') return
456
+ if (!leaving && !settled()) {
457
+ held.push(() => track(stream, name, params, only))
458
+ return
459
+ }
460
+ const c = read()
461
+ // A moment the server also states (a paid order) brings its own id, the order's,
462
+ // so each platform sees the browser's copy and the server's as one.
463
+ const event_id = typeof params.event_id === 'string' && params.event_id ? params.event_id : crypto.randomUUID()
464
+ const to = reach()
465
+ mirror(name, params, event_id, to)
466
+ if (only === 'tags') return
467
+ stream?.capture(name, {
468
+ ...params,
469
+ ...touch(c),
470
+ event_id,
471
+ consent: render(c),
472
+ tags: to,
473
+ userAgent: navigator.userAgent,
474
+ url: window.location.href,
475
+ })
476
+ }
477
+
478
+ /** What cloud needs to forward a moment a page states itself, as if the browser had sent it. */
479
+ export function visit(): Record<string, unknown> {
480
+ const c = read()
481
+ return { ...touch(c), consent: render(c), tags: reach(), userAgent: navigator.userAgent, url: window.location.href }
482
+ }