@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/dist/tags.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Analytics } from './core';
|
|
2
|
+
export interface BrowserTag {
|
|
3
|
+
platform: string;
|
|
4
|
+
type: string;
|
|
5
|
+
id: string;
|
|
6
|
+
events?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface TagOptions {
|
|
9
|
+
/** The site's publishable key; resolves its tag set. */
|
|
10
|
+
key?: string;
|
|
11
|
+
/** The site's host, for a site whose key is the org's. */
|
|
12
|
+
host?: string;
|
|
13
|
+
/** cloud's origin; defaults to https://api.hanzo.ai. */
|
|
14
|
+
base?: string;
|
|
15
|
+
/** Domains one visit crosses, so GA4 keeps it one session. */
|
|
16
|
+
domains?: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The publishable key of each site's project, which is how cloud finds the site's
|
|
20
|
+
* tag set. A `pk-` is public by design (it ships in every page); it names a site,
|
|
21
|
+
* never a platform id. A host absent here has no tag config unless `start` is
|
|
22
|
+
* given a key.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SITE_KEY: Readonly<Record<string, string>>;
|
|
25
|
+
/**
|
|
26
|
+
* Starts the tag manager: fetches the site's tag set and loads what consent
|
|
27
|
+
* allows, again on every consent change. Safe to call on every page load, and
|
|
28
|
+
* a no-op on the server. Returns a function that stops listening.
|
|
29
|
+
*/
|
|
30
|
+
export declare function start(o?: TagOptions): () => void;
|
|
31
|
+
/** The browser tags a moment can reach right now, as the `tags` property spells it. */
|
|
32
|
+
export declare function reach(): string;
|
|
33
|
+
/**
|
|
34
|
+
* Sends one moment to the browser tags named in `to`, under `id`. The names each
|
|
35
|
+
* platform knows it by are the table in @hanzo/events; a platform the table
|
|
36
|
+
* gives no name, or a rule-named platform with no rule for it, is not sent it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function mirror(name: string, params: Record<string, unknown>, id: string, to?: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* One moment, to every place it is counted, under one event_id: our stream
|
|
41
|
+
* always hears it (with the consent, the click and the browser ids cloud
|
|
42
|
+
* forwards on), and each browser tag that is running hears it too. A moment
|
|
43
|
+
* that arrives while gtag.js is still on its way waits for it, so GA4 sees it
|
|
44
|
+
* with a session; a page that is leaving sends what it holds as things stand.
|
|
45
|
+
*
|
|
46
|
+
* A paid order is stated by the SERVER on our stream (and forwarded server-side);
|
|
47
|
+
* its browser copy is `track(stream, 'order_completed', {event_id: order, order_id:
|
|
48
|
+
* order, …}, 'tags')`: the same id, the pixels only, so nothing counts twice.
|
|
49
|
+
*/
|
|
50
|
+
export declare function track(stream: Analytics | undefined, name: string, params?: Record<string, unknown>,
|
|
51
|
+
/** `'tags'` fires the browser pixels only: the server states this moment on our stream itself. */
|
|
52
|
+
only?: 'tags'): void;
|
|
53
|
+
/** What cloud needs to forward a moment a page states itself, as if the browser had sent it. */
|
|
54
|
+
export declare function visit(): Record<string, unknown>;
|
|
55
|
+
//# sourceMappingURL=tags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../src/tags.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAA;AAEvC,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUpD,CAAA;AAuNF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAE,UAAe,GAAG,MAAM,IAAI,CAmCpD;AAOD,uFAAuF;AACvF,wBAAgB,KAAK,IAAI,MAAM,CAc9B;AA+CD;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,SAAU,GAAG,IAAI,CAiCpG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,KAAK,CACnB,MAAM,EAAE,SAAS,GAAG,SAAS,EAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;AACpC,kGAAkG;AAClG,IAAI,CAAC,EAAE,MAAM,GACZ,IAAI,CAsBN;AAED,gGAAgG;AAChG,wBAAgB,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAG/C"}
|
package/dist/touch.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Choice } from './consent';
|
|
2
|
+
export declare const CLICK_IDS: readonly ['gclid', 'gbraid', 'wbraid', 'fbclid', 'li_fat_id', 'twclid', 'ttclid'];
|
|
3
|
+
/** Records this page's click and the browser ids. Call once per page load. */
|
|
4
|
+
export declare function capture(c: Choice): void;
|
|
5
|
+
/** The properties an event carries for cloud to match it on. */
|
|
6
|
+
export declare function touch(c: Choice): Record<string, string | number>;
|
|
7
|
+
//# sourceMappingURL=touch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"touch.d.ts","sourceRoot":"","sources":["../src/touch.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAGvC,eAAO,MAAM,SAAS,YAAI,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,CAAU,CAAA;AAM1G,8EAA8E;AAC9E,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAiBvC;AAED,gEAAgE;AAChE,wBAAgB,KAAK,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAmBhE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.48",
|
|
4
4
|
"description": "Hanzo Event — the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes — the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"vitest": "^4.1.11"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@hanzo/events": "0.
|
|
78
|
+
"@hanzo/events": "0.3.1"
|
|
79
79
|
},
|
|
80
80
|
"scripts": {
|
|
81
81
|
"build": "tsup && tsc --emitDeclarationOnly --declaration --outDir dist && node ../../scripts/dts.mjs",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Consent: opt-in outside the US, opt-out inside it, GPC always wins.
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { CONSENT_COOKIE, acceptAll, asks, read, region, rejectAll, save } from './consent'
|
|
5
|
+
import { touch, capture } from './touch'
|
|
6
|
+
|
|
7
|
+
const g = globalThis as Record<string, unknown>
|
|
8
|
+
const original = {
|
|
9
|
+
navigator: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
|
10
|
+
Intl: Object.getOwnPropertyDescriptor(globalThis, 'Intl'),
|
|
11
|
+
}
|
|
12
|
+
const put = (k: string, value: unknown) => Object.defineProperty(globalThis, k, { value, configurable: true, writable: true })
|
|
13
|
+
|
|
14
|
+
function browser(zone: string, opts: { gpc?: boolean; cookie?: string; href?: string } = {}) {
|
|
15
|
+
const jar = new Map<string, string>()
|
|
16
|
+
if (opts.cookie !== undefined) jar.set(CONSENT_COOKIE, opts.cookie)
|
|
17
|
+
const fired: string[] = []
|
|
18
|
+
const url = new URL(opts.href ?? 'https://hanzo.ai/')
|
|
19
|
+
g.window = {
|
|
20
|
+
location: { hostname: url.hostname, protocol: url.protocol, search: url.search, href: url.href },
|
|
21
|
+
dispatchEvent: (e: { type: string }) => void fired.push(e.type),
|
|
22
|
+
}
|
|
23
|
+
g.Event = class {
|
|
24
|
+
constructor(public type: string) {}
|
|
25
|
+
}
|
|
26
|
+
put('navigator', { globalPrivacyControl: opts.gpc === true })
|
|
27
|
+
put('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: zone }) }) })
|
|
28
|
+
g.document = {
|
|
29
|
+
get cookie() {
|
|
30
|
+
return [...jar].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ')
|
|
31
|
+
},
|
|
32
|
+
set cookie(line: string) {
|
|
33
|
+
const [pair] = line.split(';')
|
|
34
|
+
const i = pair.indexOf('=')
|
|
35
|
+
const name = pair.slice(0, i)
|
|
36
|
+
const value = decodeURIComponent(pair.slice(i + 1))
|
|
37
|
+
if (/Max-Age=0/.test(line)) jar.delete(name)
|
|
38
|
+
else jar.set(name, value)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
return { jar, fired }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
for (const k of ['window', 'document', 'Event']) delete g[k]
|
|
46
|
+
for (const [k, d] of Object.entries(original)) if (d) Object.defineProperty(globalThis, k, d)
|
|
47
|
+
vi.restoreAllMocks()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('region', () => {
|
|
51
|
+
it.each([
|
|
52
|
+
['Europe/Berlin', 'opt-in'],
|
|
53
|
+
['Europe/London', 'opt-in'],
|
|
54
|
+
['Europe/Zurich', 'opt-in'],
|
|
55
|
+
['Asia/Tokyo', 'opt-in'],
|
|
56
|
+
['America/New_York', 'opt-out'],
|
|
57
|
+
['America/Los_Angeles', 'opt-out'],
|
|
58
|
+
['America/Indiana/Indianapolis', 'opt-out'],
|
|
59
|
+
['Pacific/Honolulu', 'opt-out'],
|
|
60
|
+
['', 'opt-in'],
|
|
61
|
+
])('%s is %s', (zone, want) => {
|
|
62
|
+
browser(zone)
|
|
63
|
+
expect(region()).toBe(want)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('the default before a choice', () => {
|
|
68
|
+
it('allows nothing in the EU and asks', () => {
|
|
69
|
+
browser('Europe/Paris')
|
|
70
|
+
expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
|
|
71
|
+
expect(asks()).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('allows everything in the US and does not ask', () => {
|
|
75
|
+
browser('America/Chicago')
|
|
76
|
+
expect(read()).toEqual({ analytics: true, marketing: true, ads: true })
|
|
77
|
+
expect(asks()).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('Global Privacy Control turns marketing off in the US', () => {
|
|
81
|
+
browser('America/Denver', { gpc: true })
|
|
82
|
+
expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('GPC wins over a stored yes', () => {
|
|
86
|
+
browser('America/Denver', { gpc: true, cookie: 'analytics,marketing,ads' })
|
|
87
|
+
expect(read()).toEqual({ analytics: true, marketing: false, ads: false })
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('a stored choice', () => {
|
|
92
|
+
it('is what is read, and stops the asking', () => {
|
|
93
|
+
browser('Europe/Paris', { cookie: 'analytics' })
|
|
94
|
+
expect(read()).toEqual({ analytics: true, marketing: false, ads: false })
|
|
95
|
+
expect(asks()).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('accepting stores all three and tells the page', () => {
|
|
99
|
+
const b = browser('Europe/Paris')
|
|
100
|
+
acceptAll()
|
|
101
|
+
expect(b.jar.get(CONSENT_COOKIE)).toBe('analytics,marketing,ads')
|
|
102
|
+
expect(b.fired).toEqual(['hzconsent'])
|
|
103
|
+
expect(read()).toEqual({ analytics: true, marketing: true, ads: true })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('rejecting stores an empty choice, which is a choice', () => {
|
|
107
|
+
const b = browser('America/New_York')
|
|
108
|
+
rejectAll()
|
|
109
|
+
expect(b.jar.get(CONSENT_COOKIE)).toBe('')
|
|
110
|
+
expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('saving marketing under GPC stores none', () => {
|
|
114
|
+
const b = browser('Europe/Paris', { gpc: true })
|
|
115
|
+
save({ analytics: true, marketing: true, ads: true })
|
|
116
|
+
expect(b.jar.get(CONSENT_COOKIE)).toBe('analytics')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('click ids', () => {
|
|
121
|
+
const href = (q: string) => `https://hanzo.ai/?${q}`
|
|
122
|
+
|
|
123
|
+
it('keeps every platform click id and builds _fbc from fbclid', () => {
|
|
124
|
+
const b = browser('America/New_York', {
|
|
125
|
+
href: href('gclid=G1&gbraid=GB&wbraid=WB&fbclid=F1&li_fat_id=L1&twclid=T1&ttclid=K1'),
|
|
126
|
+
})
|
|
127
|
+
capture({ analytics: true, marketing: true, ads: true })
|
|
128
|
+
const t = touch({ analytics: true, marketing: true, ads: true })
|
|
129
|
+
expect(t).toMatchObject({ gclid: 'G1', gbraid: 'GB', wbraid: 'WB', fbclid: 'F1', li_fat_id: 'L1', twclid: 'T1', ttclid: 'K1' })
|
|
130
|
+
expect(String(t.fbc)).toMatch(/^fb\.1\.\d+\.F1$/)
|
|
131
|
+
expect(b.jar.get('hz_touch')).toContain('ttclid')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('stores no click without marketing consent', () => {
|
|
135
|
+
const b = browser('Europe/Paris', { href: href('gclid=G1') })
|
|
136
|
+
capture({ analytics: true, marketing: false, ads: false })
|
|
137
|
+
expect(b.jar.has('hz_touch')).toBe(false)
|
|
138
|
+
expect(touch({ analytics: true, marketing: false, ads: false })).not.toHaveProperty('gclid')
|
|
139
|
+
})
|
|
140
|
+
})
|
package/src/consent.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Cookie consent: what a visitor lets a site measure and share, and the rule for
|
|
2
|
+
// what a visitor who has not chosen is presumed to allow.
|
|
3
|
+
//
|
|
4
|
+
// Three categories. Analytics is our own stream and GA4. Marketing is the ad
|
|
5
|
+
// platforms measuring the conversions they bought (Google Ads, Meta, LinkedIn,
|
|
6
|
+
// X, TikTok) and every server-side send. Ads is those platforms using the visit
|
|
7
|
+
// for audiences; off, Google's ad_personalization is denied and Meta runs under
|
|
8
|
+
// Limited Data Use.
|
|
9
|
+
//
|
|
10
|
+
// THE DEFAULT depends on where the visitor is. In the EU, UK and Switzerland,
|
|
11
|
+
// and anywhere else that is not a US state, nothing is allowed until they say
|
|
12
|
+
// so: a visitor with no stored choice has allowed nothing. In the US the default
|
|
13
|
+
// is on and the choice is an opt-out ("Do not sell or share my personal
|
|
14
|
+
// information"). Global Privacy Control, and Do Not Track, turn marketing and
|
|
15
|
+
// ads off in every region whatever is stored: California reads GPC as the opt-out
|
|
16
|
+
// of sale and sharing.
|
|
17
|
+
//
|
|
18
|
+
// There is no geo lookup. The page cannot ask a server where the visitor is, so
|
|
19
|
+
// the zone is the browser's own time zone, and a zone that is not a US zone is
|
|
20
|
+
// treated as opt-in. Wrong in the safe direction: a US visitor on a foreign
|
|
21
|
+
// clock is asked, and a European is never presumed to have agreed.
|
|
22
|
+
//
|
|
23
|
+
// The choice is a first-party cookie, `hz_consent`, on the registrable domain
|
|
24
|
+
// for 13 months: the granted categories, comma separated. cloud reads the same
|
|
25
|
+
// string off each event's `consent` property.
|
|
26
|
+
|
|
27
|
+
import { get, set } from './cookie'
|
|
28
|
+
|
|
29
|
+
export const CONSENT_COOKIE = 'hz_consent'
|
|
30
|
+
|
|
31
|
+
/** Fired on `window` whenever a choice is stored. */
|
|
32
|
+
export const CONSENT_EVENT = 'hzconsent'
|
|
33
|
+
|
|
34
|
+
export interface Choice {
|
|
35
|
+
analytics: boolean
|
|
36
|
+
marketing: boolean
|
|
37
|
+
ads: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Region = 'opt-in' | 'opt-out'
|
|
41
|
+
|
|
42
|
+
type Nav = { doNotTrack?: string; msDoNotTrack?: string; globalPrivacyControl?: boolean }
|
|
43
|
+
|
|
44
|
+
const US =
|
|
45
|
+
/^(America\/(New_York|Chicago|Denver|Los_Angeles|Phoenix|Anchorage|Adak|Boise|Detroit|Juneau|Sitka|Nome|Yakutat|Menominee|Metlakatla|Indiana\/.*|Kentucky\/.*|North_Dakota\/.*)|Pacific\/Honolulu|US\/.*)$/
|
|
46
|
+
|
|
47
|
+
/** Whether the browser sends Global Privacy Control. */
|
|
48
|
+
export function gpc(): boolean {
|
|
49
|
+
if (typeof navigator === 'undefined') return false
|
|
50
|
+
return (navigator as unknown as Nav).globalPrivacyControl === true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether the browser sends Global Privacy Control or Do Not Track. */
|
|
54
|
+
export function refused(): boolean {
|
|
55
|
+
if (typeof window === 'undefined') return false
|
|
56
|
+
const n = navigator as unknown as Nav
|
|
57
|
+
const dnt = n.doNotTrack ?? (window as unknown as Nav).doNotTrack ?? n.msDoNotTrack
|
|
58
|
+
return gpc() || dnt === '1' || dnt === 'yes'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Which rule applies to this visitor. */
|
|
62
|
+
export function region(): Region {
|
|
63
|
+
try {
|
|
64
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''
|
|
65
|
+
return US.test(zone) ? 'opt-out' : 'opt-in'
|
|
66
|
+
} catch {
|
|
67
|
+
return 'opt-in'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the visitor has stored a choice. */
|
|
72
|
+
export function stored(): boolean {
|
|
73
|
+
return get(CONSENT_COOKIE) !== undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The visitor's choice: the stored one, else the regional default, with GPC applied. */
|
|
77
|
+
export function read(): Choice {
|
|
78
|
+
const kept = get(CONSENT_COOKIE)
|
|
79
|
+
const on =
|
|
80
|
+
kept !== undefined
|
|
81
|
+
? kept.split(',')
|
|
82
|
+
: region() === 'opt-in' || refused()
|
|
83
|
+
? []
|
|
84
|
+
: ['analytics', 'marketing', 'ads']
|
|
85
|
+
const has = (k: string) => on.includes(k)
|
|
86
|
+
return { analytics: has('analytics'), marketing: has('marketing') && !gpc(), ads: has('ads') && !gpc() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether the consent banner must be shown: an opt-in visitor who has not chosen. */
|
|
90
|
+
export function asks(): boolean {
|
|
91
|
+
return typeof document !== 'undefined' && region() === 'opt-in' && !stored()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The choice as the `consent` property and the cookie spell it. */
|
|
95
|
+
export function render(c: Choice): string {
|
|
96
|
+
return (['analytics', 'marketing', 'ads'] as const).filter((k) => c[k]).join(',')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stores a choice and tells the page. The tags react without a reload: a
|
|
101
|
+
* category just allowed loads its libraries, and Google's consent state is
|
|
102
|
+
* updated. An ad library already running cannot be unloaded, so a choice that
|
|
103
|
+
* withdraws marketing takes effect for what has not loaded yet and for every
|
|
104
|
+
* later page.
|
|
105
|
+
*/
|
|
106
|
+
export function save(c: Choice): void {
|
|
107
|
+
const out: Choice = { analytics: c.analytics, marketing: c.marketing && !gpc(), ads: c.ads && !gpc() }
|
|
108
|
+
set(CONSENT_COOKIE, render(out), 395)
|
|
109
|
+
if (typeof window !== 'undefined') window.dispatchEvent(new Event(CONSENT_EVENT))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Accept every category. */
|
|
113
|
+
export const acceptAll = (): void => save({ analytics: true, marketing: true, ads: true })
|
|
114
|
+
|
|
115
|
+
/** Refuse every optional category. */
|
|
116
|
+
export const rejectAll = (): void => save({ analytics: false, marketing: false, ads: false })
|
package/src/cookie.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// First-party cookies for consent, the ad click and the browser ids the ad
|
|
2
|
+
// platforms match on. Set on the registrable domain so every subdomain of a site
|
|
3
|
+
// reads the same choice and the same click; on localhost or an address it stays
|
|
4
|
+
// on the host.
|
|
5
|
+
|
|
6
|
+
const DAY = 86_400
|
|
7
|
+
|
|
8
|
+
/** The domain attribute for this host, or '' to keep the cookie on the host. */
|
|
9
|
+
function domain(): string {
|
|
10
|
+
const h = window.location.hostname
|
|
11
|
+
if (h === 'localhost' || /^[\d.]+$/.test(h) || h.includes(':')) return ''
|
|
12
|
+
const parts = h.split('.')
|
|
13
|
+
return parts.length > 2 ? `; Domain=.${parts.slice(-2).join('.')}` : `; Domain=.${h}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reads one cookie, or undefined. */
|
|
17
|
+
export function get(name: string): string | undefined {
|
|
18
|
+
if (typeof document === 'undefined') return undefined
|
|
19
|
+
const hit = document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))
|
|
20
|
+
return hit ? decodeURIComponent(hit.slice(name.length + 1)) : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Writes one cookie for `days`. */
|
|
24
|
+
export function set(name: string, value: string, days: number): void {
|
|
25
|
+
if (typeof document === 'undefined') return
|
|
26
|
+
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
|
27
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${days * DAY}; Path=/; SameSite=Lax${domain()}${secure}`
|
|
28
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -17,5 +17,5 @@
|
|
|
17
17
|
* of the name, one name per user-visible moment shared by every surface.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
export { EVENTS, PAGEVIEW, EXCEPTION } from '@hanzo/events'
|
|
20
|
+
export { EVENTS, PAGEVIEW, EXCEPTION, PLATFORMS, namesOn } from '@hanzo/events'
|
|
21
21
|
export type { EventName } from '@hanzo/events'
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,16 @@ export { scrubText, redactSecrets, scrubPII } from './scrub'
|
|
|
25
25
|
export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
|
|
26
26
|
export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
|
|
27
27
|
export type { EventName } from './events'
|
|
28
|
+
export {
|
|
29
|
+
CONSENT_COOKIE, CONSENT_EVENT, acceptAll, asks, gpc, read as readConsent, refused, region,
|
|
30
|
+
rejectAll, render as renderConsent, save as saveConsent, stored as storedConsent,
|
|
31
|
+
} from './consent'
|
|
32
|
+
export type { Choice, Region } from './consent'
|
|
33
|
+
export { CLICK_IDS, capture as captureClick, touch } from './touch'
|
|
34
|
+
export { start as startTags, track, mirror, reach, visit, SITE_KEY } from './tags'
|
|
35
|
+
export type { BrowserTag, TagOptions } from './tags'
|
|
36
|
+
export { commerceItem, worth } from './items'
|
|
37
|
+
export type { CommerceItem, PlanLine } from './items'
|
|
28
38
|
export { GOALS, COHORTS } from './goals'
|
|
29
39
|
export type { GoalDef, CohortDef } from './goals'
|
|
30
40
|
export { FUNNELS, PRODUCTS, eventsOf } from './funnels'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { commerceItem, worth } from './items'
|
|
3
|
+
|
|
4
|
+
describe('commerceItem', () => {
|
|
5
|
+
it('names an individual plan by its id, term and price', () => {
|
|
6
|
+
expect(commerceItem({ id: 'dev', name: 'Dev', interval: 'month', price: 19 })).toEqual({
|
|
7
|
+
item_id: 'dev', item_name: 'Dev', item_category: 'individual', item_variant: 'monthly', price: 19, quantity: 1,
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('names a team plan by its seats, and an annual term however commerce spells it', () => {
|
|
12
|
+
for (const interval of ['year', 'yearly', 'annual', 'Year']) {
|
|
13
|
+
const i = commerceItem({ id: 'team_standard', team: true, interval, price: 24, quantity: 3 })
|
|
14
|
+
expect(i).toMatchObject({ item_category: 'team', item_variant: 'annual', quantity: 3 })
|
|
15
|
+
expect(worth(i)).toBe(72)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('a plan with no price is free, not unknown', () => {
|
|
20
|
+
expect(commerceItem({ id: 'free', interval: 'month', price: null }).price).toBe(0)
|
|
21
|
+
})
|
|
22
|
+
})
|
package/src/items.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// A plan as every ecommerce event names it: GA4's item shape, which the tag
|
|
2
|
+
// manager hands Meta, TikTok and X, and which cloud's forwarder lifts from the
|
|
3
|
+
// events the server states. One function, so a plan carries the same line from
|
|
4
|
+
// the pricing page to the checkout to the order.
|
|
5
|
+
//
|
|
6
|
+
// `item_id` is the plan's id as the catalog spells it (free, dev, max, and the
|
|
7
|
+
// team plans), `item_category` says who it is for, `item_variant` the term,
|
|
8
|
+
// `price` the unit price for that term in US dollars and `quantity` the seats
|
|
9
|
+
// (1 for an individual plan).
|
|
10
|
+
|
|
11
|
+
export interface CommerceItem {
|
|
12
|
+
item_id: string
|
|
13
|
+
item_name?: string
|
|
14
|
+
item_category: 'individual' | 'team'
|
|
15
|
+
item_variant: 'monthly' | 'annual'
|
|
16
|
+
price: number
|
|
17
|
+
quantity: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PlanLine {
|
|
21
|
+
id: string
|
|
22
|
+
name?: string
|
|
23
|
+
/** A team plan is sold per seat. */
|
|
24
|
+
team?: boolean
|
|
25
|
+
/** The term, as commerce (`month`, `year`) or the funnel (`monthly`, `annual`) says it. */
|
|
26
|
+
interval: string
|
|
27
|
+
price: number | null
|
|
28
|
+
quantity?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function commerceItem(p: PlanLine): CommerceItem {
|
|
32
|
+
const annual = /^(year|yearly|annual)$/i.test(p.interval)
|
|
33
|
+
return {
|
|
34
|
+
item_id: p.id,
|
|
35
|
+
...(p.name ? { item_name: p.name } : {}),
|
|
36
|
+
item_category: p.team ? 'team' : 'individual',
|
|
37
|
+
item_variant: annual ? 'annual' : 'monthly',
|
|
38
|
+
price: p.price ?? 0,
|
|
39
|
+
quantity: p.quantity ?? 1,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What the whole order is worth: the unit price times the seats. */
|
|
44
|
+
export const worth = (i: CommerceItem): number => i.price * i.quantity
|
package/src/react.tsx
CHANGED
|
@@ -16,11 +16,13 @@ import {
|
|
|
16
16
|
useContext,
|
|
17
17
|
useEffect,
|
|
18
18
|
useRef,
|
|
19
|
+
useSyncExternalStore,
|
|
19
20
|
type ErrorInfo,
|
|
20
21
|
type ReactNode,
|
|
21
22
|
} from 'react'
|
|
22
23
|
import { Analytics, createAnalytics } from './core'
|
|
23
24
|
import type { AnalyticsConfig } from './types'
|
|
25
|
+
import { CONSENT_EVENT, read, render, type Choice } from './consent'
|
|
24
26
|
|
|
25
27
|
const Ctx = createContext<Analytics | null>(null)
|
|
26
28
|
|
|
@@ -120,3 +122,23 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|
|
120
122
|
return fallback ?? null
|
|
121
123
|
}
|
|
122
124
|
}
|
|
125
|
+
|
|
126
|
+
const listen = (on: () => void) => {
|
|
127
|
+
window.addEventListener(CONSENT_EVENT, on)
|
|
128
|
+
return () => window.removeEventListener(CONSENT_EVENT, on)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The visitor's consent, live: what a region presumes until they choose, then
|
|
133
|
+
* what they chose, re-rendering the moment they do. `enabled: choice.analytics`
|
|
134
|
+
* on the provider's config is how a stream that started silent for an EU visitor
|
|
135
|
+
* starts on Accept, with no reload.
|
|
136
|
+
*/
|
|
137
|
+
export function useConsent(): Choice {
|
|
138
|
+
const kept = useSyncExternalStore(listen, () => render(read()), () => '')
|
|
139
|
+
return {
|
|
140
|
+
analytics: kept.includes('analytics'),
|
|
141
|
+
marketing: kept.includes('marketing'),
|
|
142
|
+
ads: kept.includes('ads'),
|
|
143
|
+
}
|
|
144
|
+
}
|