@hanzo/event 0.3.44 → 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/index.cjs +37 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +35 -7
- 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 +14 -1
- package/dist/tags.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +3 -1
- package/src/items.test.ts +22 -0
- package/src/items.ts +44 -0
- package/src/react.tsx +22 -0
- package/src/tags.test.ts +18 -0
- package/src/tags.ts +38 -5
package/dist/tags.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export interface TagOptions {
|
|
|
15
15
|
/** Domains one visit crosses, so GA4 keeps it one session. */
|
|
16
16
|
domains?: string[];
|
|
17
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>>;
|
|
18
25
|
/**
|
|
19
26
|
* Starts the tag manager: fetches the site's tag set and loads what consent
|
|
20
27
|
* allows, again on every consent change. Safe to call on every page load, and
|
|
@@ -35,8 +42,14 @@ export declare function mirror(name: string, params: Record<string, unknown>, id
|
|
|
35
42
|
* forwards on), and each browser tag that is running hears it too. A moment
|
|
36
43
|
* that arrives while gtag.js is still on its way waits for it, so GA4 sees it
|
|
37
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.
|
|
38
49
|
*/
|
|
39
|
-
export declare function track(stream: Analytics | undefined, name: string, params?: Record<string, unknown
|
|
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;
|
|
40
53
|
/** What cloud needs to forward a moment a page states itself, as if the browser had sent it. */
|
|
41
54
|
export declare function visit(): Record<string, unknown>;
|
|
42
55
|
//# sourceMappingURL=tags.d.ts.map
|
package/dist/tags.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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/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/",
|
package/src/index.ts
CHANGED
|
@@ -31,8 +31,10 @@ export {
|
|
|
31
31
|
} from './consent'
|
|
32
32
|
export type { Choice, Region } from './consent'
|
|
33
33
|
export { CLICK_IDS, capture as captureClick, touch } from './touch'
|
|
34
|
-
export { start as startTags, track, mirror, reach, visit } from './tags'
|
|
34
|
+
export { start as startTags, track, mirror, reach, visit, SITE_KEY } from './tags'
|
|
35
35
|
export type { BrowserTag, TagOptions } from './tags'
|
|
36
|
+
export { commerceItem, worth } from './items'
|
|
37
|
+
export type { CommerceItem, PlanLine } from './items'
|
|
36
38
|
export { GOALS, COHORTS } from './goals'
|
|
37
39
|
export type { GoalDef, CohortDef } from './goals'
|
|
38
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
|
+
}
|
package/src/tags.test.ts
CHANGED
|
@@ -185,3 +185,21 @@ describe('one event, every tag', () => {
|
|
|
185
185
|
expect(sent.items[0].price).toBe(0)
|
|
186
186
|
})
|
|
187
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
CHANGED
|
@@ -54,6 +54,24 @@ export interface TagOptions {
|
|
|
54
54
|
domains?: string[]
|
|
55
55
|
}
|
|
56
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
|
+
|
|
57
75
|
type Call = (...args: unknown[]) => void
|
|
58
76
|
type Page = {
|
|
59
77
|
dataLayer?: unknown[]
|
|
@@ -281,8 +299,10 @@ export function start(o: TagOptions = {}): () => void {
|
|
|
281
299
|
configured = true
|
|
282
300
|
const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
|
|
283
301
|
const q = new URLSearchParams()
|
|
284
|
-
|
|
285
|
-
|
|
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)
|
|
286
306
|
const answer = (list: BrowserTag[]) => {
|
|
287
307
|
tags = list
|
|
288
308
|
configAnswered = true
|
|
@@ -420,17 +440,30 @@ export function mirror(name: string, params: Record<string, unknown>, id: string
|
|
|
420
440
|
* forwards on), and each browser tag that is running hears it too. A moment
|
|
421
441
|
* that arrives while gtag.js is still on its way waits for it, so GA4 sees it
|
|
422
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.
|
|
423
447
|
*/
|
|
424
|
-
export function track(
|
|
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 {
|
|
425
455
|
if (typeof window === 'undefined') return
|
|
426
456
|
if (!leaving && !settled()) {
|
|
427
|
-
held.push(() => track(stream, name, params))
|
|
457
|
+
held.push(() => track(stream, name, params, only))
|
|
428
458
|
return
|
|
429
459
|
}
|
|
430
460
|
const c = read()
|
|
431
|
-
|
|
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()
|
|
432
464
|
const to = reach()
|
|
433
465
|
mirror(name, params, event_id, to)
|
|
466
|
+
if (only === 'tags') return
|
|
434
467
|
stream?.capture(name, {
|
|
435
468
|
...params,
|
|
436
469
|
...touch(c),
|