@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.
- package/dist/consent.d.ts +36 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/cookie.d.ts +5 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.cjs +508 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +487 -38
- package/dist/index.mjs.map +1 -1
- package/dist/items.d.ts +22 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/react.cjs +64 -12
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +8 -0
- package/dist/react.d.ts +8 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.mjs +65 -14
- package/dist/react.mjs.map +1 -1
- package/dist/tags.d.ts +55 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/touch.d.ts +7 -0
- package/dist/touch.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/consent.test.ts +140 -0
- package/src/consent.ts +116 -0
- package/src/cookie.ts +28 -0
- package/src/events.ts +1 -1
- package/src/index.ts +10 -0
- package/src/items.test.ts +22 -0
- package/src/items.ts +44 -0
- package/src/react.tsx +22 -0
- package/src/tags.test.ts +205 -0
- package/src/tags.ts +482 -0
- package/src/touch.ts +68 -0
package/src/tags.test.ts
ADDED
|
@@ -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
|
+
}
|