@hanzo/event 0.3.43 → 0.3.44
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 +477 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +459 -38
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/tags.d.ts +42 -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 +8 -0
- package/src/tags.test.ts +187 -0
- package/src/tags.ts +449 -0
- package/src/touch.ts +68 -0
package/src/tags.ts
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
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
|
+
type Call = (...args: unknown[]) => void
|
|
58
|
+
type Page = {
|
|
59
|
+
dataLayer?: unknown[]
|
|
60
|
+
gtag?: Call
|
|
61
|
+
fbq?: Call & { queue?: unknown[]; callMethod?: Call; push?: unknown; loaded?: boolean; version?: string }
|
|
62
|
+
_fbq?: unknown
|
|
63
|
+
lintrk?: Call & { q?: unknown[] }
|
|
64
|
+
_linkedin_data_partner_ids?: string[]
|
|
65
|
+
twq?: Call & { queue?: unknown[]; exe?: Call; version?: string }
|
|
66
|
+
ttq?: Record<string, Call> & { _i?: Record<string, unknown>; _t?: Record<string, number>; _o?: Record<string, unknown>; methods?: string[]; load?: Call; page?: Call; track?: Call }
|
|
67
|
+
TiktokAnalyticsObject?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const page = (): Page => window as unknown as Page
|
|
71
|
+
|
|
72
|
+
let tags: BrowserTag[] = []
|
|
73
|
+
let configured = false
|
|
74
|
+
let options: TagOptions = {}
|
|
75
|
+
const loaded = new Set<string>()
|
|
76
|
+
|
|
77
|
+
/** gtag.js: 'idle' until fetched, 'wait' while on its way, then how it answered. */
|
|
78
|
+
let google: 'idle' | 'wait' | 'loaded' | 'failed' = 'idle'
|
|
79
|
+
let configAnswered = false
|
|
80
|
+
|
|
81
|
+
const held: Array<() => void> = []
|
|
82
|
+
let leaving = false
|
|
83
|
+
|
|
84
|
+
function settled(): boolean {
|
|
85
|
+
return configAnswered && google !== 'wait'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function flush(): void {
|
|
89
|
+
if (settled() || leaving) held.splice(0).forEach((send) => send())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function script(src: string, onload?: () => void, onerror?: () => void): void {
|
|
93
|
+
const s = document.createElement('script')
|
|
94
|
+
s.async = true
|
|
95
|
+
s.src = src
|
|
96
|
+
if (onload) s.onload = onload
|
|
97
|
+
if (onerror) s.onerror = onerror
|
|
98
|
+
document.head.appendChild(s)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Google ──────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function gtagStub(): Call {
|
|
104
|
+
const p = page()
|
|
105
|
+
p.dataLayer = p.dataLayer || []
|
|
106
|
+
if (!p.gtag) {
|
|
107
|
+
// gtag.js reads the `arguments` object itself, so this is a function and not an arrow.
|
|
108
|
+
p.gtag = function () {
|
|
109
|
+
// eslint-disable-next-line prefer-rest-params
|
|
110
|
+
p.dataLayer!.push(arguments)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return p.gtag
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const g = (on: boolean) => (on ? 'granted' : 'denied')
|
|
117
|
+
|
|
118
|
+
/** Google Consent Mode v2: denied until said otherwise, then the visitor's choice. */
|
|
119
|
+
function consentMode(c: Choice, first: boolean): void {
|
|
120
|
+
const gtag = gtagStub()
|
|
121
|
+
const state = {
|
|
122
|
+
analytics_storage: g(c.analytics),
|
|
123
|
+
ad_storage: g(c.marketing),
|
|
124
|
+
ad_user_data: g(c.marketing),
|
|
125
|
+
ad_personalization: g(c.marketing && c.ads),
|
|
126
|
+
}
|
|
127
|
+
if (first) {
|
|
128
|
+
gtag('consent', 'default', {
|
|
129
|
+
analytics_storage: 'denied',
|
|
130
|
+
ad_storage: 'denied',
|
|
131
|
+
ad_user_data: 'denied',
|
|
132
|
+
ad_personalization: 'denied',
|
|
133
|
+
wait_for_update: 500,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
gtag('consent', 'update', state)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function loadGoogle(ids: string[]): void {
|
|
140
|
+
const gtag = gtagStub()
|
|
141
|
+
const fresh = ids.filter((id) => !loaded.has(id))
|
|
142
|
+
if (!fresh.length) return
|
|
143
|
+
if (google === 'idle') {
|
|
144
|
+
google = 'wait'
|
|
145
|
+
gtag('js', new Date())
|
|
146
|
+
const done = (to: 'loaded' | 'failed') => () => {
|
|
147
|
+
if (google !== 'wait') return
|
|
148
|
+
google = to
|
|
149
|
+
flush()
|
|
150
|
+
}
|
|
151
|
+
script(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(fresh[0])}`, done('loaded'), done('failed'))
|
|
152
|
+
// gtag.js answers once: loaded, failed (a blocker), or silent past 8 s.
|
|
153
|
+
setTimeout(done('failed'), 8000)
|
|
154
|
+
}
|
|
155
|
+
for (const id of fresh) {
|
|
156
|
+
loaded.add(id)
|
|
157
|
+
gtag('config', id, options.domains ? { linker: { domains: options.domains } } : {})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── The other pixels ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function loadMeta(id: string, c: Choice): void {
|
|
164
|
+
const p = page()
|
|
165
|
+
if (!p.fbq) {
|
|
166
|
+
const n = (p.fbq = function (...a: unknown[]) {
|
|
167
|
+
if (n.callMethod) n.callMethod(...a)
|
|
168
|
+
else n.queue!.push(a)
|
|
169
|
+
} as NonNullable<Page['fbq']>)
|
|
170
|
+
if (!p._fbq) p._fbq = n
|
|
171
|
+
n.push = n
|
|
172
|
+
n.loaded = true
|
|
173
|
+
n.version = '2.0'
|
|
174
|
+
n.queue = []
|
|
175
|
+
script('https://connect.facebook.net/en_US/fbevents.js')
|
|
176
|
+
}
|
|
177
|
+
if (!c.ads) p.fbq('dataProcessingOptions', ['LDU'], 0, 0)
|
|
178
|
+
p.fbq('init', id)
|
|
179
|
+
p.fbq('track', 'PageView')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function loadLinkedIn(id: string): void {
|
|
183
|
+
const p = page()
|
|
184
|
+
p._linkedin_data_partner_ids = p._linkedin_data_partner_ids || []
|
|
185
|
+
p._linkedin_data_partner_ids.push(id)
|
|
186
|
+
if (!p.lintrk) {
|
|
187
|
+
const l = (p.lintrk = function (...a: unknown[]) {
|
|
188
|
+
l.q!.push(a)
|
|
189
|
+
} as NonNullable<Page['lintrk']>)
|
|
190
|
+
l.q = []
|
|
191
|
+
}
|
|
192
|
+
script('https://snap.licdn.com/li.lms-analytics/insight.min.js')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function loadX(id: string): void {
|
|
196
|
+
const p = page()
|
|
197
|
+
if (!p.twq) {
|
|
198
|
+
const s = (p.twq = function (...a: unknown[]) {
|
|
199
|
+
if (s.exe) s.exe(...a)
|
|
200
|
+
else s.queue!.push(a)
|
|
201
|
+
} as NonNullable<Page['twq']>)
|
|
202
|
+
s.version = '1.1'
|
|
203
|
+
s.queue = []
|
|
204
|
+
script('https://static.ads-twitter.com/uwt.js')
|
|
205
|
+
}
|
|
206
|
+
p.twq('config', id)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const TIKTOK_METHODS = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie']
|
|
210
|
+
|
|
211
|
+
function loadTikTok(id: string): void {
|
|
212
|
+
const p = page()
|
|
213
|
+
p.TiktokAnalyticsObject = 'ttq'
|
|
214
|
+
const q = (p.ttq = p.ttq || ([] as unknown as NonNullable<Page['ttq']>)) as unknown as Record<string, unknown> & unknown[]
|
|
215
|
+
if (!q._i) {
|
|
216
|
+
q.methods = TIKTOK_METHODS
|
|
217
|
+
for (const m of TIKTOK_METHODS) q[m] = (...a: unknown[]) => q.push([m, ...a])
|
|
218
|
+
q._i = {}
|
|
219
|
+
q._t = {}
|
|
220
|
+
q._o = {}
|
|
221
|
+
q.load = (sdk: string) => {
|
|
222
|
+
;(q._i as Record<string, unknown>)[sdk] = []
|
|
223
|
+
;(q._t as Record<string, number>)[sdk] = Date.now()
|
|
224
|
+
script(`https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(sdk)}&lib=ttq`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
;(q.load as Call)(id)
|
|
228
|
+
;(q.page as Call)()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Which tag is allowed and running ────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
const NEEDS: Record<string, keyof Choice> = {
|
|
234
|
+
ga: 'analytics',
|
|
235
|
+
gads: 'marketing',
|
|
236
|
+
meta: 'marketing',
|
|
237
|
+
linkedin: 'marketing',
|
|
238
|
+
x: 'marketing',
|
|
239
|
+
tiktok: 'marketing',
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** GA4 also counts a visit when only Marketing is allowed (Consent Mode keeps it cookieless). */
|
|
243
|
+
const allowed = (t: BrowserTag, c: Choice): boolean =>
|
|
244
|
+
t.type === 'ga' ? c.analytics || c.marketing : Boolean(NEEDS[t.type] && c[NEEDS[t.type]])
|
|
245
|
+
|
|
246
|
+
function apply(): void {
|
|
247
|
+
if (typeof window === 'undefined' || !configured) return
|
|
248
|
+
const c = read()
|
|
249
|
+
capture(c)
|
|
250
|
+
const on = tags.filter((t) => allowed(t, c))
|
|
251
|
+
const ids = on.filter((t) => t.type === 'ga' || t.type === 'gads').map((t) => t.id)
|
|
252
|
+
if (ids.length) {
|
|
253
|
+
consentMode(c, !loaded.has('consent'))
|
|
254
|
+
loaded.add('consent')
|
|
255
|
+
loadGoogle(ids)
|
|
256
|
+
}
|
|
257
|
+
for (const t of on) {
|
|
258
|
+
const k = `${t.type}:${t.id}`
|
|
259
|
+
if (loaded.has(k)) continue
|
|
260
|
+
if (t.type === 'meta') loadMeta(t.id, c)
|
|
261
|
+
else if (t.type === 'linkedin') loadLinkedIn(t.id)
|
|
262
|
+
else if (t.type === 'x') loadX(t.id)
|
|
263
|
+
else if (t.type === 'tiktok') loadTikTok(t.id)
|
|
264
|
+
else continue
|
|
265
|
+
loaded.add(k)
|
|
266
|
+
}
|
|
267
|
+
flush()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Starts the tag manager: fetches the site's tag set and loads what consent
|
|
272
|
+
* allows, again on every consent change. Safe to call on every page load, and
|
|
273
|
+
* a no-op on the server. Returns a function that stops listening.
|
|
274
|
+
*/
|
|
275
|
+
export function start(o: TagOptions = {}): () => void {
|
|
276
|
+
if (typeof window === 'undefined') return () => undefined
|
|
277
|
+
options = o
|
|
278
|
+
addEventListener(CONSENT_EVENT, apply)
|
|
279
|
+
addEventListener('pagehide', pagehide)
|
|
280
|
+
if (!configured) {
|
|
281
|
+
configured = true
|
|
282
|
+
const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
|
|
283
|
+
const q = new URLSearchParams()
|
|
284
|
+
if (o.key) q.set('key', o.key)
|
|
285
|
+
q.set('host', o.host ?? window.location.hostname)
|
|
286
|
+
const answer = (list: BrowserTag[]) => {
|
|
287
|
+
tags = list
|
|
288
|
+
configAnswered = true
|
|
289
|
+
apply()
|
|
290
|
+
}
|
|
291
|
+
// A page never waits on its tag config: an unreachable cloud is an empty set.
|
|
292
|
+
const cap = setTimeout(() => answer([]), 3000)
|
|
293
|
+
fetch(`${base}/v1/project/tags?${q}`)
|
|
294
|
+
.then((r) => (r.ok ? r.json() : { tags: [] }))
|
|
295
|
+
.then((j: { tags?: BrowserTag[] }) => {
|
|
296
|
+
clearTimeout(cap)
|
|
297
|
+
answer(Array.isArray(j.tags) ? j.tags : [])
|
|
298
|
+
})
|
|
299
|
+
.catch(() => {
|
|
300
|
+
clearTimeout(cap)
|
|
301
|
+
answer([])
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
return () => {
|
|
305
|
+
removeEventListener(CONSENT_EVENT, apply)
|
|
306
|
+
removeEventListener('pagehide', pagehide)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function pagehide(): void {
|
|
311
|
+
leaving = true
|
|
312
|
+
flush()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The browser tags a moment can reach right now, as the `tags` property spells it. */
|
|
316
|
+
export function reach(): string {
|
|
317
|
+
const c = read()
|
|
318
|
+
const to: string[] = []
|
|
319
|
+
for (const t of tags) {
|
|
320
|
+
if (!allowed(t, c)) continue
|
|
321
|
+
const running =
|
|
322
|
+
t.type === 'ga'
|
|
323
|
+
? google === 'loaded' && c.analytics
|
|
324
|
+
: t.type === 'gads'
|
|
325
|
+
? google === 'loaded' && c.marketing
|
|
326
|
+
: loaded.has(`${t.type}:${t.id}`)
|
|
327
|
+
if (running && !to.includes(t.type)) to.push(t.type)
|
|
328
|
+
}
|
|
329
|
+
return to.join(',')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Firing an event ─────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
interface Item {
|
|
335
|
+
item_id: string
|
|
336
|
+
item_name?: string
|
|
337
|
+
item_category?: string
|
|
338
|
+
item_variant?: string
|
|
339
|
+
price?: number
|
|
340
|
+
quantity?: number
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const META_STANDARD = new Set([
|
|
344
|
+
'AddPaymentInfo', 'AddToCart', 'AddToWishlist', 'CompleteRegistration', 'Contact', 'CustomizeProduct',
|
|
345
|
+
'Donate', 'FindLocation', 'InitiateCheckout', 'Lead', 'Purchase', 'Schedule', 'Search', 'StartTrial',
|
|
346
|
+
'SubmitApplication', 'Subscribe', 'ViewContent',
|
|
347
|
+
])
|
|
348
|
+
|
|
349
|
+
function shape(platform: 'meta' | 'tiktok' | 'x', p: Record<string, unknown>): Record<string, unknown> {
|
|
350
|
+
const items = Array.isArray(p.items) ? (p.items as Item[]) : []
|
|
351
|
+
const out: Record<string, unknown> = {}
|
|
352
|
+
if (typeof p.value === 'number') out.value = p.value
|
|
353
|
+
out.currency = typeof p.currency === 'string' ? p.currency : 'USD'
|
|
354
|
+
if (!items.length) return out
|
|
355
|
+
if (platform === 'meta') {
|
|
356
|
+
out.content_type = 'product'
|
|
357
|
+
out.content_ids = items.map((i) => i.item_id)
|
|
358
|
+
out.contents = items.map((i) => ({ id: i.item_id, quantity: i.quantity ?? 1, item_price: i.price }))
|
|
359
|
+
out.content_name = items[0].item_name
|
|
360
|
+
out.num_items = items.reduce((n, i) => n + (i.quantity ?? 1), 0)
|
|
361
|
+
} else if (platform === 'tiktok') {
|
|
362
|
+
out.content_type = 'product'
|
|
363
|
+
out.contents = items.map((i) => ({ content_id: i.item_id, content_name: i.item_name, quantity: i.quantity ?? 1, price: i.price }))
|
|
364
|
+
} else {
|
|
365
|
+
out.contents = items.map((i) => ({ content_id: i.item_id, content_name: i.item_name, content_price: i.price, num_items: i.quantity ?? 1 }))
|
|
366
|
+
}
|
|
367
|
+
return out
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A test order states no amount: nothing sent for it can be summed into revenue. */
|
|
371
|
+
function unpriced(p: Record<string, unknown>): Record<string, unknown> {
|
|
372
|
+
const out: Record<string, unknown> = { ...p, value: 0 }
|
|
373
|
+
if (Array.isArray(p.items)) out.items = (p.items as Item[]).map((i) => ({ ...i, price: 0 }))
|
|
374
|
+
return out
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Sends one moment to the browser tags named in `to`, under `id`. The names each
|
|
379
|
+
* platform knows it by are the table in @hanzo/events; a platform the table
|
|
380
|
+
* gives no name, or a rule-named platform with no rule for it, is not sent it.
|
|
381
|
+
*/
|
|
382
|
+
export function mirror(name: string, params: Record<string, unknown>, id: string, to = reach()): void {
|
|
383
|
+
const p = page()
|
|
384
|
+
const on = new Set(to.split(',').filter(Boolean))
|
|
385
|
+
const ga4 = namesOn(name, 'ga4')
|
|
386
|
+
const test = params.test === true
|
|
387
|
+
if (test) params = unpriced(params)
|
|
388
|
+
const transaction = typeof params.order_id === 'string' ? { transaction_id: params.order_id } : {}
|
|
389
|
+
for (const t of tags) {
|
|
390
|
+
if (!on.has(t.type)) continue
|
|
391
|
+
const rule = t.events?.[name]
|
|
392
|
+
if (t.type === 'ga') {
|
|
393
|
+
for (const n of ga4) {
|
|
394
|
+
p.gtag?.('event', n, { ...params, ...transaction, event_id: id, transport_type: 'beacon', ...(test ? { debug_mode: true } : {}) })
|
|
395
|
+
}
|
|
396
|
+
} else if (t.type === 'gads' && rule) {
|
|
397
|
+
p.gtag?.('event', 'conversion', {
|
|
398
|
+
send_to: rule,
|
|
399
|
+
value: typeof params.value === 'number' ? params.value : undefined,
|
|
400
|
+
currency: typeof params.currency === 'string' ? params.currency : 'USD',
|
|
401
|
+
...transaction,
|
|
402
|
+
})
|
|
403
|
+
} else if (t.type === 'meta') {
|
|
404
|
+
for (const n of namesOn(name, 'meta')) {
|
|
405
|
+
p.fbq?.(META_STANDARD.has(n) ? 'track' : 'trackCustom', n, shape('meta', params), { eventID: id })
|
|
406
|
+
}
|
|
407
|
+
} else if (t.type === 'tiktok') {
|
|
408
|
+
for (const n of namesOn(name, 'tiktok')) p.ttq?.track?.(n, shape('tiktok', params), { event_id: id })
|
|
409
|
+
} else if (t.type === 'linkedin' && rule && namesOn(name, 'linkedin').length) {
|
|
410
|
+
p.lintrk?.('track', { conversion_id: Number(rule) })
|
|
411
|
+
} else if (t.type === 'x' && rule && namesOn(name, 'x').length) {
|
|
412
|
+
p.twq?.('event', rule, { ...shape('x', params), conversion_id: id })
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* One moment, to every place it is counted, under one event_id: our stream
|
|
419
|
+
* always hears it (with the consent, the click and the browser ids cloud
|
|
420
|
+
* forwards on), and each browser tag that is running hears it too. A moment
|
|
421
|
+
* that arrives while gtag.js is still on its way waits for it, so GA4 sees it
|
|
422
|
+
* with a session; a page that is leaving sends what it holds as things stand.
|
|
423
|
+
*/
|
|
424
|
+
export function track(stream: Analytics | undefined, name: string, params: Record<string, unknown> = {}): void {
|
|
425
|
+
if (typeof window === 'undefined') return
|
|
426
|
+
if (!leaving && !settled()) {
|
|
427
|
+
held.push(() => track(stream, name, params))
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
const c = read()
|
|
431
|
+
const event_id = crypto.randomUUID()
|
|
432
|
+
const to = reach()
|
|
433
|
+
mirror(name, params, event_id, to)
|
|
434
|
+
stream?.capture(name, {
|
|
435
|
+
...params,
|
|
436
|
+
...touch(c),
|
|
437
|
+
event_id,
|
|
438
|
+
consent: render(c),
|
|
439
|
+
tags: to,
|
|
440
|
+
userAgent: navigator.userAgent,
|
|
441
|
+
url: window.location.href,
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** What cloud needs to forward a moment a page states itself, as if the browser had sent it. */
|
|
446
|
+
export function visit(): Record<string, unknown> {
|
|
447
|
+
const c = read()
|
|
448
|
+
return { ...touch(c), consent: render(c), tags: reach(), userAgent: navigator.userAgent, url: window.location.href }
|
|
449
|
+
}
|
package/src/touch.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The ad click a visitor arrived on, kept first-party so a conversion days later
|
|
2
|
+
// is credited to it even when the ad tags never loaded.
|
|
3
|
+
//
|
|
4
|
+
// On every page load the click ids in the address are read: gclid, gbraid and
|
|
5
|
+
// wbraid (Google), fbclid (Meta), li_fat_id (LinkedIn), twclid (X), ttclid
|
|
6
|
+
// (TikTok). When any is there the whole set replaces the last one in `hz_touch`
|
|
7
|
+
// with the time it was captured: the last click is the one credited. It is kept
|
|
8
|
+
// 90 days, the lookback cloud applies before it offers a click to a platform
|
|
9
|
+
// (apps/destination/touch.go).
|
|
10
|
+
//
|
|
11
|
+
// Beside it are the browser ids the platforms match on. `_fbc` is Meta's click
|
|
12
|
+
// cookie, built from fbclid in the shape the Pixel would write it; `_fbp` is its
|
|
13
|
+
// browser id. `cid` is GA4's client id: the last two fields of `_ga`, or a
|
|
14
|
+
// first-party `hz_cid` of the same shape when gtag.js never ran.
|
|
15
|
+
//
|
|
16
|
+
// Nothing here runs without consent: the click and the Meta ids need Marketing,
|
|
17
|
+
// the client id needs Analytics.
|
|
18
|
+
|
|
19
|
+
import type { Choice } from './consent'
|
|
20
|
+
import { get, set } from './cookie'
|
|
21
|
+
|
|
22
|
+
export const CLICK_IDS = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'li_fat_id', 'twclid', 'ttclid'] as const
|
|
23
|
+
|
|
24
|
+
const DAYS = 90
|
|
25
|
+
const now = () => Math.floor(Date.now() / 1000)
|
|
26
|
+
const rand = () => Math.floor(Math.random() * 2 ** 31)
|
|
27
|
+
|
|
28
|
+
/** Records this page's click and the browser ids. Call once per page load. */
|
|
29
|
+
export function capture(c: Choice): void {
|
|
30
|
+
if (typeof window === 'undefined') return
|
|
31
|
+
if (c.marketing) {
|
|
32
|
+
const q = new URLSearchParams(window.location.search)
|
|
33
|
+
const click: Record<string, string | number> = {}
|
|
34
|
+
for (const k of CLICK_IDS) {
|
|
35
|
+
const v = q.get(k)?.trim()
|
|
36
|
+
if (v) click[k] = v
|
|
37
|
+
}
|
|
38
|
+
if (Object.keys(click).length) {
|
|
39
|
+
click.clicked = now()
|
|
40
|
+
set('hz_touch', JSON.stringify(click), DAYS)
|
|
41
|
+
if (click.fbclid) set('_fbc', `fb.1.${Date.now()}.${click.fbclid}`, DAYS)
|
|
42
|
+
}
|
|
43
|
+
if (!get('_fbp')) set('_fbp', `fb.1.${Date.now()}.${rand()}`, DAYS)
|
|
44
|
+
}
|
|
45
|
+
if (c.analytics && !get('_ga') && !get('hz_cid')) set('hz_cid', `${rand()}.${now()}`, 730)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The properties an event carries for cloud to match it on. */
|
|
49
|
+
export function touch(c: Choice): Record<string, string | number> {
|
|
50
|
+
const out: Record<string, string | number> = {}
|
|
51
|
+
if (c.marketing) {
|
|
52
|
+
try {
|
|
53
|
+
Object.assign(out, JSON.parse(get('hz_touch') ?? '{}'))
|
|
54
|
+
} catch {
|
|
55
|
+
/* a cookie that does not parse names no click */
|
|
56
|
+
}
|
|
57
|
+
const fbc = get('_fbc')
|
|
58
|
+
const fbp = get('_fbp')
|
|
59
|
+
if (fbc) out.fbc = fbc
|
|
60
|
+
if (fbp) out.fbp = fbp
|
|
61
|
+
}
|
|
62
|
+
if (c.analytics) {
|
|
63
|
+
const ga = get('_ga')?.split('.')
|
|
64
|
+
const cid = ga && ga.length >= 4 ? ga.slice(-2).join('.') : get('hz_cid')
|
|
65
|
+
if (cid) out.cid = cid
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|