@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/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>): void;
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
@@ -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;AAuND;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAE,UAAe,GAAG,MAAM,IAAI,CAiCpD;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;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,IAAI,CAmB7G;AAED,gGAAgG;AAChG,wBAAgB,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAG/C"}
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.44",
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
- if (o.key) q.set('key', o.key)
285
- q.set('host', o.host ?? window.location.hostname)
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(stream: Analytics | undefined, name: string, params: Record<string, unknown> = {}): void {
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
- const event_id = crypto.randomUUID()
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),