@buildinams/core 0.3.0 → 0.4.0
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/README.md +24 -1
- package/dist/analytics/google/index.d.ts +158 -0
- package/dist/analytics/google/index.js +274 -0
- package/dist/analytics/google/index.js.map +1 -0
- package/dist/analytics/index.d.ts +101 -0
- package/dist/analytics/index.js +384 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/shopify/index.d.ts +186 -0
- package/dist/analytics/shopify/index.js +175 -0
- package/dist/analytics/shopify/index.js.map +1 -0
- package/dist/{chunk-IAAQVL6M.js → chunk-FJ7SNQQI.js} +24 -5
- package/dist/chunk-FJ7SNQQI.js.map +1 -0
- package/dist/chunk-G2BKC4BU.js +8 -0
- package/dist/chunk-G2BKC4BU.js.map +1 -0
- package/dist/chunk-GQVN2ECX.js +14 -0
- package/dist/chunk-GQVN2ECX.js.map +1 -0
- package/dist/chunk-LL3VTFNY.js +22 -0
- package/dist/chunk-LL3VTFNY.js.map +1 -0
- package/dist/chunk-M3HNM5HC.js +48 -0
- package/dist/chunk-M3HNM5HC.js.map +1 -0
- package/dist/chunk-MHFGMH3G.js +11 -0
- package/dist/chunk-MHFGMH3G.js.map +1 -0
- package/dist/chunk-OS2T6P2T.js +50 -0
- package/dist/chunk-OS2T6P2T.js.map +1 -0
- package/dist/chunk-QIJGG2F4.js +51 -0
- package/dist/chunk-QIJGG2F4.js.map +1 -0
- package/dist/consent/cookiebot/index.d.ts +16 -0
- package/dist/consent/cookiebot/index.js +27 -0
- package/dist/consent/cookiebot/index.js.map +1 -0
- package/dist/consent/google/index.d.ts +23 -0
- package/dist/consent/google/index.js +48 -0
- package/dist/consent/google/index.js.map +1 -0
- package/dist/consent/index.d.ts +1 -0
- package/dist/consent/index.js +3 -0
- package/dist/consent/index.js.map +1 -0
- package/dist/consent/onetrust/index.d.ts +23 -0
- package/dist/consent/onetrust/index.js +39 -0
- package/dist/consent/onetrust/index.js.map +1 -0
- package/dist/consent/shopify/index.d.ts +44 -0
- package/dist/consent/shopify/index.js +84 -0
- package/dist/consent/shopify/index.js.map +1 -0
- package/dist/cookies/index.d.ts +230 -0
- package/dist/cookies/index.js +188 -0
- package/dist/cookies/index.js.map +1 -0
- package/dist/define-events-CxZjoUlG.d.ts +255 -0
- package/dist/destination-5KJ5PeR3.d.ts +122 -0
- package/dist/index-Zef1nnTP.d.ts +36 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +64 -1
- package/dist/index.js.map +1 -1
- package/dist/listeners-DkU-vBPa.d.ts +15 -0
- package/dist/navigation/index.d.ts +1 -1
- package/dist/navigation/index.js +1 -1
- package/dist/schema/index.d.ts +16 -9
- package/dist/schema/index.js +1 -1
- package/dist/schema-CfQvhbuU.d.ts +87 -0
- package/dist/store/index.js +1 -1
- package/dist/tracking/cart/index.d.ts +74 -0
- package/dist/tracking/cart/index.js +118 -0
- package/dist/tracking/cart/index.js.map +1 -0
- package/dist/tracking/dom/index.d.ts +320 -0
- package/dist/tracking/dom/index.js +1196 -0
- package/dist/tracking/dom/index.js.map +1 -0
- package/docs/analytics.md +883 -0
- package/docs/cookies.md +354 -0
- package/docs/decisions/001-analytics-project-events.md +31 -0
- package/docs/decisions/002-tracking-dom-opt-in.md +29 -0
- package/docs/decisions/003-tracking-element-data.md +27 -0
- package/docs/decisions/004-cookies-explicit-store.md +26 -0
- package/docs/decisions/005-cookies-invalid-reads-fall-back.md +27 -0
- package/docs/decisions/006-cookies-server-only-types.md +19 -0
- package/docs/decisions/007-cookies-options-on-definition.md +25 -0
- package/docs/decisions/008-cookies-in-vertical-slices.md +24 -0
- package/docs/decisions/009-analytics-consent-targets.md +29 -0
- package/docs/decisions/010-analytics-destination-contract.md +29 -0
- package/docs/decisions/011-tracking-decoupled.md +30 -0
- package/docs/tracking.md +221 -0
- package/package.json +84 -3
- package/dist/chunk-IAAQVL6M.js.map +0 -1
- package/dist/chunk-SIU7SKJK.js +0 -139
- package/dist/chunk-SIU7SKJK.js.map +0 -1
- package/dist/schema-iy92gbTC.d.ts +0 -36
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
# Analytics
|
|
2
|
+
|
|
3
|
+
One typed `track()` for every provider, gated by the visitor's consent.
|
|
4
|
+
|
|
5
|
+
- **Three declarations:** the project declares its events in a catalog, each destination declares the events it accepts, and a mapping connects the two. A missing field or an unknown event is a compile error.
|
|
6
|
+
- **Your events, your data:** [tracking](tracking.md) reports what happened (page views, clicks, product views, cart changes), with your own product data in it, e.g. `{ product, variant }`, and analytics tracks it under the same name. Mappings turn them into each provider's events, e.g. GA4's recommended ones for GTM.
|
|
7
|
+
- **A readable catalog:** every event describes what it means, and `analytics.routes()` lists which destinations receive it.
|
|
8
|
+
- **Consent first:** events are queued until consent is answered, then only reach the destinations the visitor allowed.
|
|
9
|
+
- **Browser only:** `track()` on the server does nothing. A prerendered page delivers nothing until the visitor navigates to it.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
### 1. Add tracking
|
|
14
|
+
|
|
15
|
+
The markup watcher and cart changes report page views, product views, selections and cart changes, so most call sites need no `analytics.track()`. Your product, list, promotion and cart item types are declared here, on their plugins. Create them in a module without side effects, so server components can import `track()` without loading analytics:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// src/analytics/tracking.ts
|
|
19
|
+
import { createCartChanges } from "@buildinams/core/tracking/cart";
|
|
20
|
+
import {
|
|
21
|
+
createDomWatcher,
|
|
22
|
+
ecommerce,
|
|
23
|
+
pageViews,
|
|
24
|
+
} from "@buildinams/core/tracking/dom";
|
|
25
|
+
|
|
26
|
+
export type Item = { product: ProductFragment; variant?: VariantFragment };
|
|
27
|
+
export type List = { id: string; name: string };
|
|
28
|
+
export type Promotion = { id: string; name: string; slot?: string };
|
|
29
|
+
|
|
30
|
+
export const domWatcher = createDomWatcher([
|
|
31
|
+
pageViews(),
|
|
32
|
+
ecommerce<{ item: Item; list: List; promotion: Promotion }>({
|
|
33
|
+
id: ({ product }) => product.id,
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export const { track } = domWatcher;
|
|
38
|
+
|
|
39
|
+
export const cartChanges = createCartChanges<Item>();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
See [Tracking](tracking.md) for every plugin and how to mark elements.
|
|
43
|
+
|
|
44
|
+
### 2. Declare your events
|
|
45
|
+
|
|
46
|
+
One catalog with every event the project emits: what tracking reports, from the trackers' `events`, and your own. See [The catalog](#the-catalog).
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// src/analytics/event-catalog.ts
|
|
50
|
+
import { defineEvents, type EventsOf, shape } from "@buildinams/core/analytics";
|
|
51
|
+
|
|
52
|
+
import { cartChanges, domWatcher } from "./tracking";
|
|
53
|
+
|
|
54
|
+
export const events = defineEvents({
|
|
55
|
+
...domWatcher.events,
|
|
56
|
+
...cartChanges.events,
|
|
57
|
+
newsletterSignedUp: {
|
|
58
|
+
description: "The newsletter form was submitted successfully.",
|
|
59
|
+
payload: shape<{ source: "footer" | "popup" }>(),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export type Events = EventsOf<typeof events>;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Create the instance
|
|
67
|
+
|
|
68
|
+
One module, imported wherever you track. Consent comes from your consent platform ([Consent](#consent)); each destination is mapped onto your events, then given the consent it requires ([Destinations and mappings](#destinations-and-mappings)). `sources` tracks what the trackers report, under the same name.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// src/analytics/index.ts
|
|
72
|
+
import {
|
|
73
|
+
createAnalytics,
|
|
74
|
+
map,
|
|
75
|
+
mapEvery,
|
|
76
|
+
withConsent,
|
|
77
|
+
} from "@buildinams/core/analytics";
|
|
78
|
+
import {
|
|
79
|
+
toGa4CustomEvent,
|
|
80
|
+
ga4Mapping,
|
|
81
|
+
gtmDestination,
|
|
82
|
+
} from "@buildinams/core/analytics/google";
|
|
83
|
+
import { cookiebotConsent } from "@buildinams/core/consent/cookiebot";
|
|
84
|
+
|
|
85
|
+
import { events, type Events } from "./event-catalog";
|
|
86
|
+
import { cartChanges, domWatcher } from "./tracking";
|
|
87
|
+
|
|
88
|
+
export const analytics = createAnalytics<Events>({
|
|
89
|
+
events,
|
|
90
|
+
consent: cookiebotConsent(),
|
|
91
|
+
sources: [domWatcher, cartChanges],
|
|
92
|
+
destinations: [
|
|
93
|
+
withConsent(
|
|
94
|
+
map(gtmDestination(), {
|
|
95
|
+
...mapEvery(events, toGa4CustomEvent),
|
|
96
|
+
...ga4Mapping<Events>({ item: toGa4Item }),
|
|
97
|
+
}),
|
|
98
|
+
"none",
|
|
99
|
+
),
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Option | Default | What it does |
|
|
105
|
+
| ---------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
106
|
+
| `events` | — | The catalog from `defineEvents()`. |
|
|
107
|
+
| `consent` | — | The consent source. Events are queued while it's unanswered. |
|
|
108
|
+
| `consentTargets` | `[]` | Providers told every answer. See [Consent targets](#consent-targets). |
|
|
109
|
+
| `destinations` | `[]` | Where events go, each from `withConsent()`. |
|
|
110
|
+
| `sources` | `[]` | Trackers whose events are tracked under the same name, e.g. `domWatcher`. See [Sources](#sources). |
|
|
111
|
+
| `context` | `{}` | Initial [context](#context) for every event. |
|
|
112
|
+
| `delivery` | `"deferred"` | `"deferred"` delivers after the next paint, so tracking in a click handler doesn't hurt INP; pending events go out straight away when the page is hidden or, with the Navigation API, before a client-side navigation. `"immediate"` delivers as tracked, e.g. in tests. |
|
|
113
|
+
|
|
114
|
+
The instance has `track()`, `setContext()`, `routes()` ([Routes](#routes)) and `subscribe(listener)`, which receives every event as tracked, before consent and delivery, and returns an unsubscribe function.
|
|
115
|
+
|
|
116
|
+
### 4. Track
|
|
117
|
+
|
|
118
|
+
Mark elements, and track the rest from code:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
<main {...track({ pageView: { pageType: "product" } })}>
|
|
122
|
+
|
|
123
|
+
analytics.track("newsletterSignedUp", { source: "footer" });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Names and payloads are typed by the catalog. Payloads whose fields are all optional can be left out. Payloads are copied with `structuredClone`, so keep them plain and lean: a fragment with what the mappings read, not the whole product. `track()` never throws: a payload it can't copy is logged and dropped.
|
|
127
|
+
|
|
128
|
+
## Events
|
|
129
|
+
|
|
130
|
+
### The catalog
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
export const events = defineEvents({
|
|
134
|
+
...domWatcher.events,
|
|
135
|
+
...cartChanges.events,
|
|
136
|
+
orderPlaced: {
|
|
137
|
+
description: "An order was placed, on the thank-you page. Once per order.",
|
|
138
|
+
payload: shape<{ order: OrderFragment }>(),
|
|
139
|
+
},
|
|
140
|
+
loggedOut: { description: "The visitor logged out from the account menu." },
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **`defineEvents(catalog)`** returns the catalog as is, for `createAnalytics({ events })`. `EventsOf<typeof events>` is the `Events` type for `createAnalytics<Events>()`, `ga4Mapping<Events>()` and `shopifyMapping<Events>()`.
|
|
145
|
+
- **`description`** is required: when the event is tracked and what it means, for developers, agents and analysts.
|
|
146
|
+
- **`shape<T>()`** is the payload's type as a value, holding nothing at runtime. Leave `payload` out for events without fields.
|
|
147
|
+
- **`domWatcher.events` and `cartChanges.events`** have an entry for exactly what the installed plugins and cart changes report, under the names they report it, typed with your data and fields as the plugins declare them.
|
|
148
|
+
- **Names** are camelCase and past tense, like tracking's: `orderPlaced`, `loggedIn`, `newsletterSignedUp`. Mappings give each provider its own names.
|
|
149
|
+
- **Only what's in it:** `track()` only takes catalog events, and mappings only have to cover them. In development, a name outside it, e.g. through a cast, logs a warning once.
|
|
150
|
+
- **Extra fields** from markup are declared on the plugin, e.g. `clicks<{ cta: string }>()` adds `cta` to `clicked` and its catalog entry. For data from elsewhere, track the event yourself.
|
|
151
|
+
|
|
152
|
+
### Sources
|
|
153
|
+
|
|
154
|
+
`createAnalytics({ sources: [domWatcher, cartChanges] })` subscribes to each source in the browser, which starts it, and tracks what it reports under the same name.
|
|
155
|
+
|
|
156
|
+
- **Type-checked:** every event a source reports must be in the catalog with a compatible payload, so spread its `events`.
|
|
157
|
+
- **One instance per source:** a later `createAnalytics()` with the same source, e.g. when HMR re-runs the module, takes it over, so nothing is tracked twice.
|
|
158
|
+
- **Your own names:** to track an occurrence under another name, [subscribe to the tracker yourself](tracking.md#subscribing) instead.
|
|
159
|
+
- **Any `subscribe()`** works as a source: an `AnalyticsSource<Events>` reports `{ name, payload }` of catalog events.
|
|
160
|
+
|
|
161
|
+
## Context
|
|
162
|
+
|
|
163
|
+
Every event carries a `context`: `url`, `title` and `referrer`, plus session data you set. `setContext()` merges into it, and `undefined` clears a field. Each event copies the context when tracked, so queued events keep the values from when they happened.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
createAnalytics<Events>({ /* … */ context: { locale: "nl-NL", market: "NL" } });
|
|
167
|
+
|
|
168
|
+
// after login
|
|
169
|
+
analytics.setContext({ user: { id: customer.id, type: "returning" } });
|
|
170
|
+
|
|
171
|
+
// after logout
|
|
172
|
+
analytics.setContext({ user: undefined });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| Field | Type | What it is |
|
|
176
|
+
| -------- | -------------------------------- | ---------------------------------------------------------------------------- |
|
|
177
|
+
| `user` | `{ id?, email?, phone?, type? }` | The logged-in visitor. `phone` in E.164, `type` is `"new"` or `"returning"`. |
|
|
178
|
+
| `locale` | `string` | BCP 47, e.g. `"nl-NL"`. Take it from the route, not `<html lang>`. |
|
|
179
|
+
| `market` | `string` | The storefront market (ISO 3166-1 alpha-2), not where the visitor is. |
|
|
180
|
+
|
|
181
|
+
Add fields, including user fields, with the second type argument. Don't redeclare a built-in one; an undeclared key is a compile error:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
createAnalytics<
|
|
185
|
+
Events,
|
|
186
|
+
{ storefront?: "web" | "app"; user?: { loyaltyTier?: "silver" | "gold" } }
|
|
187
|
+
>({ /* … */ context: { storefront: "web" } });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Destinations decide which fields they forward. Personal data (`email`, `phone`) should only leave the browser with marketing consent; the GTM destination checks this for you.
|
|
191
|
+
|
|
192
|
+
**Page type** belongs to the page, not the session, so it's on `pageViewed`: `analytics.track("pageViewed", { pageType: "product" })`. Suggested values are `"home"`, `"collection"`, `"product"`, `"search"`, `"cart"`, `"checkout"`, `"account"` and `"content"`, but any string works. GTM remembers it for later events.
|
|
193
|
+
|
|
194
|
+
## Destinations and mappings
|
|
195
|
+
|
|
196
|
+
A destination accepts events in its provider's own vocabulary, each with a minimum shape. `map()` maps your events onto them, and returns a destination that accepts yours. `withConsent()` gives that the consent it requires, for `createAnalytics()`:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
withConsent(
|
|
200
|
+
map(metaDestination(META_PIXEL_ID), {
|
|
201
|
+
pageViewed: () => ({ name: "PageView", payload: {} }),
|
|
202
|
+
addedToCart: ({ currency, value, items }) => ({
|
|
203
|
+
name: "AddToCart",
|
|
204
|
+
payload: {
|
|
205
|
+
content_ids: items.map(({ item }) => item.merchandise.id),
|
|
206
|
+
content_type: "product",
|
|
207
|
+
currency,
|
|
208
|
+
value,
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
newsletterSignedUp: ({ source }) => ({
|
|
212
|
+
name: "Lead",
|
|
213
|
+
payload: { content_name: source },
|
|
214
|
+
}),
|
|
215
|
+
}),
|
|
216
|
+
["marketing"],
|
|
217
|
+
);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- **Typed both ways:** each entry gets its event's payload and must return an event the destination accepts, with every required field.
|
|
221
|
+
- **One, several or none:** return one event, an array, or `null`. The second argument has the event's `id`, `name` and `context`.
|
|
222
|
+
- **`withConsent()`'s second argument** is mandatory: the consent categories that must all be granted, or `"none"` for providers that gate on consent themselves, like GTM with Consent Mode.
|
|
223
|
+
- **Events without an entry** aren't sent; in development, creating analytics warns about catalog events no destination receives.
|
|
224
|
+
- **`mapEvery(events, toEvent)`** has an entry for every catalog event, e.g. `mapEvery(events, toGa4CustomEvent)` for GTM. Spread it first, so the entries after it override it.
|
|
225
|
+
- **Builders** map what tracking reports for a destination over adapters for your data: [`ga4Mapping()`](#ga4mapping) and [`shopifyMapping()`](#shopify). Spread one to extend or override it; an entry set to `undefined` sends nothing.
|
|
226
|
+
- **Your own events, unmapped:** a destination typed by your `Events`, e.g. a log, skips `map()`: `withConsent(logDestination, "none")`.
|
|
227
|
+
- **Outside `createAnalytics()`**, `map()` and `withConsent()` can't infer your events: annotate the result, `const meta: ConsentedDestination<Events> = withConsent(map(…), …)`.
|
|
228
|
+
- **Mapped twice**, e.g. with two mappings and different consent, a destination is set up once and receives what both send.
|
|
229
|
+
|
|
230
|
+
### Routes
|
|
231
|
+
|
|
232
|
+
`analytics.routes()` lists, per catalog event, its description and the destinations that receive it, with the consent they wait for. Destinations are keyed by `name`, numbered when several share one (`gtm`, `gtm (2)`). It works on the server too, without consent.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
analytics.routes();
|
|
236
|
+
// {
|
|
237
|
+
// pageViewed: {
|
|
238
|
+
// description: "A page was shown: on load, after a client-side navigation, …",
|
|
239
|
+
// destinations: {
|
|
240
|
+
// gtm: { requires: "none" },
|
|
241
|
+
// shopify: { requires: ["analytics"] },
|
|
242
|
+
// },
|
|
243
|
+
// },
|
|
244
|
+
// newsletterSignedUp: {
|
|
245
|
+
// description: "The newsletter form was submitted successfully.",
|
|
246
|
+
// destinations: { gtm: { requires: "none" } },
|
|
247
|
+
// },
|
|
248
|
+
// }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
It's the project's tracking plan, read from the code. Snapshot it in a test, `expect(analytics.routes()).toMatchSnapshot()`, so changes to what reaches which provider show up in review, or print it as JSON for agents and analysts.
|
|
252
|
+
|
|
253
|
+
## Consent
|
|
254
|
+
|
|
255
|
+
Three categories, mapped onto what each provider expects. Strictly necessary storage is always allowed.
|
|
256
|
+
|
|
257
|
+
| Category | Google Consent Mode v2 | Shopify Customer Privacy | Meta Pixel |
|
|
258
|
+
| ------------- | -------------------------------------------------- | ------------------------------------- | ---------------- |
|
|
259
|
+
| `preferences` | `functionality_storage`, `personalization_storage` | `preferences` | — |
|
|
260
|
+
| `analytics` | `analytics_storage` | `analytics` | — |
|
|
261
|
+
| `marketing` | `ad_storage`, `ad_user_data`, `ad_personalization` | `marketing`, read with `sale_of_data` | `grant`/`revoke` |
|
|
262
|
+
|
|
263
|
+
1. **`requires: "none"`** destinations receive every event straight away; their callbacks get the current answer, `null` until there is one.
|
|
264
|
+
2. **Destinations with categories** wait: their events are queued (the last 100) until the visitor answers.
|
|
265
|
+
3. **On an answer**, every [consent target](#consent-targets) is told, then the queue flushes to the destinations now allowed. Repeated values are ignored.
|
|
266
|
+
4. **After that**, events go straight to allowed destinations. One that loses consent receives nothing until it's granted again.
|
|
267
|
+
|
|
268
|
+
### Consent platforms
|
|
269
|
+
|
|
270
|
+
| Source | Answered when | Mapping |
|
|
271
|
+
| --------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
272
|
+
| `cookiebotConsent()` from `consent/cookiebot` | The visitor responds, or Cookiebot implies consent where it isn't required. | `preferences`, `statistics` → `analytics`, `marketing` |
|
|
273
|
+
| `oneTrustConsent()` from `consent/onetrust` | The visitor closes the banner, or straight away where OneTrust doesn't show one. | Groups `C0003` → `preferences`, `C0002` → `analytics`, `C0004` → `marketing` |
|
|
274
|
+
| `shopifyConsent()` from `consent/shopify` | The visitor responds, or straight away where Shopify shows no banner. | `preferences`, `analytics`, `marketing` only with `sale_of_data` too |
|
|
275
|
+
|
|
276
|
+
- **Load the platform's script yourself.** The source reads its global and listens to its events, so load order doesn't matter.
|
|
277
|
+
- **OneTrust group ids** differ per setup; all listed groups must be active: `oneTrustConsent({ groups: { marketing: ["C0004", "C0005"] } })`.
|
|
278
|
+
- **On the server**, sources report unanswered.
|
|
279
|
+
- **Shopify's `marketing`** needs sale of data allowed too, as Shopify blocks pixels that count as a sale for visitors who opt out.
|
|
280
|
+
|
|
281
|
+
### Consent targets
|
|
282
|
+
|
|
283
|
+
A source decides consent; a target is told it ([ADR 009](decisions/009-analytics-consent-targets.md)), e.g. for Shopify's checkout, which reads it from the Customer Privacy API, or Google's tags, which read Consent Mode.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
createAnalytics<Events>({
|
|
287
|
+
events,
|
|
288
|
+
consent: cookiebotConsent(),
|
|
289
|
+
consentTargets: [shopifyCustomerPrivacy()],
|
|
290
|
+
destinations: [
|
|
291
|
+
/* … */
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
| Target | Tells |
|
|
297
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
298
|
+
| `shopifyCustomerPrivacy()` from `consent/shopify` | Shopify's Customer Privacy API, with `sale_of_data` from `saleOfData` (`false` by default). |
|
|
299
|
+
| `googleConsentMode()` from `consent/google` | Google Consent Mode v2. See [Consent Mode](#consent-mode). |
|
|
300
|
+
|
|
301
|
+
- **Every answer, first:** targets are told before destinations receive events under it. One that throws is logged; the others are still told.
|
|
302
|
+
- **Never back to the source:** a target named like the source is skipped, so Shopify's separate sale of data opt-out isn't overwritten, and targets can stay listed when you switch platforms.
|
|
303
|
+
|
|
304
|
+
A target is a `name` and `apply(consent)`, which may return a promise:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
export const metaConsent: ConsentTarget = {
|
|
308
|
+
name: "meta",
|
|
309
|
+
apply: (consent) =>
|
|
310
|
+
window.fbq?.("consent", consent.marketing ? "grant" : "revoke"),
|
|
311
|
+
};
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Writing a consent source
|
|
315
|
+
|
|
316
|
+
A `ConsentSource` is `get()`, returning the answer or `null`, and `subscribe(listener)`:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
export const myPlatformConsent: ConsentSource = {
|
|
320
|
+
get: () => {
|
|
321
|
+
const state = window.MyPlatform?.consent;
|
|
322
|
+
if (!state?.answered) return null;
|
|
323
|
+
return {
|
|
324
|
+
preferences: state.functional,
|
|
325
|
+
analytics: state.analytics,
|
|
326
|
+
marketing: state.ads,
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
subscribe: (listener) => {
|
|
330
|
+
const onChange = () => {
|
|
331
|
+
const consent = myPlatformConsent.get();
|
|
332
|
+
if (consent) listener(consent);
|
|
333
|
+
};
|
|
334
|
+
window.addEventListener("myplatform:consent", onChange);
|
|
335
|
+
return () => window.removeEventListener("myplatform:consent", onChange);
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Only call `listener` with answered consent; repeats are fine. Give it a `name` when a target exists for the same platform.
|
|
341
|
+
|
|
342
|
+
## Google Tag Manager
|
|
343
|
+
|
|
344
|
+
`gtmDestination({ dataLayerName })` from `@buildinams/core/analytics/google` pushes events to `window.dataLayer`, as in [Quick start](#3-create-the-instance). Load GTM itself in the root layout, e.g. with `<GoogleTagManager />` from `@next/third-parties/google`, after the consent platform's script. In the container, fire the GA4 tag on the custom `page_view` event and turn off GA4's own page views, outbound clicks and scrolls (`send_page_view: false`, Enhanced Measurement), or they're counted twice.
|
|
345
|
+
|
|
346
|
+
It accepts (`GtmEvents`) every GA4 event in GA4's own params, and `custom` for any other name. Ecommerce events are pushed with their params under `ecommerce`, after a `{ ecommerce: null }` reset; others at the top level, and `custom` as `{ event, ...params }`. Each push has `event_id` and the context as variables: `user_id`, `user_type`, `language`, `market`, your own fields in snake_case, and `user_data` with email and phone only with marketing consent.
|
|
347
|
+
|
|
348
|
+
### `ga4Mapping()`
|
|
349
|
+
|
|
350
|
+
`ga4Mapping<Events>(adapters)` maps what tracking reports to [GA4's recommended events](https://developers.google.com/analytics/devguides/collection/ga4/reference/events), with snake_case params:
|
|
351
|
+
|
|
352
|
+
| Occurrence | GA4 event |
|
|
353
|
+
| --------------------------------------------------- | ------------------------------------------------- |
|
|
354
|
+
| `pageViewed`, `clicked`, `scrolled` | `page_view`, `click`, `scroll` |
|
|
355
|
+
| `videoStarted`, `videoProgressed`, `videoCompleted` | `video_start`, `video_progress`, `video_complete` |
|
|
356
|
+
| `formStarted`, `formSubmitted` | `form_start`, `form_submit` |
|
|
357
|
+
| `itemViewed`, `itemSelected`, `itemsViewed` | `view_item`, `select_item`, `view_item_list` |
|
|
358
|
+
| `promotionViewed`, `promotionSelected` | `view_promotion`, `select_promotion` |
|
|
359
|
+
| `addedToCart`, `removedFromCart` | `add_to_cart`, `remove_from_cart` |
|
|
360
|
+
|
|
361
|
+
`page_view` has `page_location`, `page_path`, `page_title`, `page_referrer` and `page_type`. The ecommerce and cart events carry your data, so they need adapters:
|
|
362
|
+
|
|
363
|
+
| Adapter | Type | Maps |
|
|
364
|
+
| ----------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
365
|
+
| `item` | `(item) => Ga4Item` | The ecommerce events. Placed items get `index`, and the list's and promotion's params. |
|
|
366
|
+
| `list` | `(list) => Ga4List` | `item_list_id` and `item_list_name`. |
|
|
367
|
+
| `promotion` | `(promotion) => Ga4Promotion` | `promotion_id`, `promotion_name`, `creative_name` and `creative_slot`. |
|
|
368
|
+
| `currency` | `(item, context) => string \| undefined` | `currency` on `itemViewed` → `view_item`, with `value` as `price` × `quantity` (default 1). |
|
|
369
|
+
| `cartItem` | `(item) => Ga4Item` | `addedToCart` → `add_to_cart` and `removedFromCart` → `remove_from_cart`, with `price` and `quantity` from the change. |
|
|
370
|
+
|
|
371
|
+
`cartItem` defaults to `item` when the cart item type is assignable to the item type; otherwise it's required alongside `item`.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
const toGa4Item = ({ product, variant }: Item): Ga4Item => ({
|
|
375
|
+
item_id: product.id,
|
|
376
|
+
item_name: product.title,
|
|
377
|
+
item_brand: product.vendor,
|
|
378
|
+
item_variant: variant?.title,
|
|
379
|
+
price: Number((variant?.price ?? product.priceRange.minVariantPrice).amount),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
ga4Mapping<Events>({
|
|
383
|
+
item: toGa4Item,
|
|
384
|
+
cartItem: ({ merchandise }: ShopifyStorefrontCartLine) => ({
|
|
385
|
+
item_id: merchandise.product.id,
|
|
386
|
+
item_name: merchandise.product.title,
|
|
387
|
+
item_variant: merchandise.title,
|
|
388
|
+
}),
|
|
389
|
+
currency: ({ product, variant }) =>
|
|
390
|
+
(variant?.price ?? product.priceRange.minVariantPrice).currencyCode,
|
|
391
|
+
list: ({ id, name }) => ({ item_list_id: id, item_list_name: name }),
|
|
392
|
+
promotion: ({ id, name, slot }) => ({
|
|
393
|
+
promotion_id: id,
|
|
394
|
+
promotion_name: name,
|
|
395
|
+
creative_slot: slot,
|
|
396
|
+
}),
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- **Same `item_id` everywhere:** `item` and `cartItem` should give a product the same id, so reports can join views, adds and purchases.
|
|
401
|
+
- **Without an adapter**, its events aren't mapped.
|
|
402
|
+
- **`toGa4CustomEvent`**, through `mapEvery()`, sends every other event under its own name and params in snake_case, e.g. `newsletterSignedUp` as `newsletter_signed_up`. It drops ecommerce events with a development warning, as GA4 would read them with an earlier event's `ecommerce`.
|
|
403
|
+
|
|
404
|
+
### Consent Mode
|
|
405
|
+
|
|
406
|
+
Cookiebot and OneTrust set up [Google Consent Mode](https://developers.google.com/tag-platform/security/guides/consent) through their own integration; the destination doesn't send consent itself. `requires` picks Google's setup:
|
|
407
|
+
|
|
408
|
+
| `requires` | Setup | GTM receives events |
|
|
409
|
+
| --------------- | -------- | --------------------------------------------------------------------------------------------- |
|
|
410
|
+
| `"none"` | Advanced | Straight away. Consent Mode gates tags in the container, and Google models visitors who deny. |
|
|
411
|
+
| `["analytics"]` | Basic | Only with analytics consent. Use it when the container has tags that ignore Consent Mode. |
|
|
412
|
+
|
|
413
|
+
For platforms without that integration, like Shopify's, add the `googleConsentMode({ dataLayerName, waitForUpdate })` target. Created before GTM loads, it sends every category denied (with `wait_for_update: 500`), then an update for every answer.
|
|
414
|
+
|
|
415
|
+
### Adjusting what GTM receives
|
|
416
|
+
|
|
417
|
+
Spread `ga4Mapping()` to map your own events, or override and drop its entries. Return GA4's params as GA4 names them:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
withConsent(
|
|
421
|
+
map(gtmDestination(), {
|
|
422
|
+
...mapEvery(events, toGa4CustomEvent),
|
|
423
|
+
...ga4Mapping<Events>({ item: toGa4Item }),
|
|
424
|
+
orderPlaced: ({ order }) => ({
|
|
425
|
+
name: "purchase",
|
|
426
|
+
payload: {
|
|
427
|
+
transaction_id: order.name,
|
|
428
|
+
currency: order.totalPrice.currencyCode,
|
|
429
|
+
value: Number(order.totalPrice.amount),
|
|
430
|
+
items: order.lineItems.map(toGa4OrderItem),
|
|
431
|
+
},
|
|
432
|
+
}),
|
|
433
|
+
productListViewed: ({ list, products }) => ({
|
|
434
|
+
name: "view_item_list",
|
|
435
|
+
payload: {
|
|
436
|
+
item_list_id: list?.id,
|
|
437
|
+
item_list_name: list?.name,
|
|
438
|
+
items: products.map(toGa4Item),
|
|
439
|
+
},
|
|
440
|
+
}),
|
|
441
|
+
// The container's trigger listens for another name.
|
|
442
|
+
newsletterSignedUp: ({ source }) => ({
|
|
443
|
+
name: "custom",
|
|
444
|
+
payload: { event: "newsletter", params: { form_location: source } },
|
|
445
|
+
}),
|
|
446
|
+
formStarted: () => null,
|
|
447
|
+
}),
|
|
448
|
+
"none",
|
|
449
|
+
);
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## Shopify
|
|
453
|
+
|
|
454
|
+
`shopifyDestination()` from `@buildinams/core/analytics/shopify` publishes to Hydrogen's analytics bus (`window.Shopify.analytics`), which `<ShopifyScripts>` from `@shopify/hydrogen/react` sets up and sends to Shopify Analytics, as in [vercel/shop](https://github.com/vercel/shop). Your project installs Hydrogen. For Liquid themes, see [Shopify Liquid themes](#shopify-liquid-themes).
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
// src/analytics/index.ts
|
|
458
|
+
export const customerPrivacy = shopifyCustomerPrivacy();
|
|
459
|
+
|
|
460
|
+
const toShopifyProduct = ({ product, variant }: Item): ShopifyProduct | null =>
|
|
461
|
+
variant
|
|
462
|
+
? {
|
|
463
|
+
id: product.id,
|
|
464
|
+
title: product.title,
|
|
465
|
+
vendor: product.vendor,
|
|
466
|
+
productType: product.productType,
|
|
467
|
+
variantId: variant.id,
|
|
468
|
+
variantTitle: variant.title,
|
|
469
|
+
price: variant.price,
|
|
470
|
+
quantity: 1,
|
|
471
|
+
}
|
|
472
|
+
: null;
|
|
473
|
+
|
|
474
|
+
export const analytics = createAnalytics<Events>({
|
|
475
|
+
events,
|
|
476
|
+
consent: cookiebotConsent(),
|
|
477
|
+
consentTargets: [customerPrivacy],
|
|
478
|
+
destinations: [
|
|
479
|
+
/* GTM, … */
|
|
480
|
+
withConsent(
|
|
481
|
+
map(
|
|
482
|
+
shopifyDestination(),
|
|
483
|
+
shopifyMapping<Events>({
|
|
484
|
+
product: toShopifyProduct,
|
|
485
|
+
cartProduct: toStorefrontCartProduct,
|
|
486
|
+
getCartId: () => cartStore.get()?.id,
|
|
487
|
+
}),
|
|
488
|
+
),
|
|
489
|
+
["analytics"],
|
|
490
|
+
),
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
496
|
+
// src/components/shopify-analytics.tsx, rendered in the root layout
|
|
497
|
+
"use client";
|
|
498
|
+
|
|
499
|
+
import { ShopifyScripts } from "@shopify/hydrogen/react";
|
|
500
|
+
|
|
501
|
+
import { customerPrivacy } from "@/analytics";
|
|
502
|
+
|
|
503
|
+
export const ShopifyAnalytics = () => (
|
|
504
|
+
<ShopifyScripts
|
|
505
|
+
analytics={{ channel: "headless" }}
|
|
506
|
+
consent={{ mode: "custom-banner", setup: customerPrivacy.syncConsent }}
|
|
507
|
+
shop={{
|
|
508
|
+
myshopifyDomain: process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!,
|
|
509
|
+
// The numeric shop id, not the storefront id.
|
|
510
|
+
shopId: process.env.NEXT_PUBLIC_SHOPIFY_SHOP_ID!,
|
|
511
|
+
}}
|
|
512
|
+
i18n={{ country: "NL", language: "NL" }}
|
|
513
|
+
shopifyAnalytics
|
|
514
|
+
/>
|
|
515
|
+
);
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
The Customer Privacy API also needs Hydrogen's same-origin Storefront API proxy (`handleShopifyRoutes` in `proxy.ts`).
|
|
519
|
+
|
|
520
|
+
| `shopifyMapping()` option | What it does |
|
|
521
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
522
|
+
| `product` | Your item as a `ShopifyProduct`, or `null` to leave it out. `itemViewed` only reaches Shopify with it. |
|
|
523
|
+
| `cartProduct` | Your cart item as a `ShopifyProduct` without `price` and `quantity`. `toStorefrontCartProduct` does this for `toStorefrontCartSnapshot()`. |
|
|
524
|
+
| `getCartId` | The current cart's GID, read on delivery. `addedToCart` only reaches Shopify with it and `cartProduct`. |
|
|
525
|
+
|
|
526
|
+
### Consent
|
|
527
|
+
|
|
528
|
+
Give it `withConsent(…, ["analytics"])`: Hydrogen's bus only delivers with analytics consent anyway. Consent comes from one of two places:
|
|
529
|
+
|
|
530
|
+
- **Your consent platform** (Cookiebot, OneTrust), as above, through the `shopifyCustomerPrivacy()` target, also for checkout. `saleOfData` is a boolean or a function, e.g. `(consent) => consent.marketing`. Its `syncConsent` is the `setup` for Hydrogen's `"custom-banner"` mode, and releases Hydrogen's bus once Shopify has an answer.
|
|
531
|
+
- **Shopify's own banner**: `consent: shopifyConsent()`, `consentTargets: [shopifyCustomerPrivacy(), googleConsentMode()]`, and `<ShopifyScripts consent={{ mode: "default-banner" }} />`. The Shopify target is then skipped, so it can stay listed, and `syncConsent` isn't needed: as a setup it would wait forever (in development it warns after 10 seconds). Shopify's banner has no Consent Mode integration on a headless storefront, hence `googleConsentMode()`. It decides per region whether to show, is styled in the Shopify admin, and has no IAB TCF support.
|
|
532
|
+
|
|
533
|
+
### Events
|
|
534
|
+
|
|
535
|
+
| Event | Published as | Notes |
|
|
536
|
+
| ------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
537
|
+
| `pageViewed` | `page_viewed` | Shopify skips it on product, collection and search pages, which send their own. |
|
|
538
|
+
| `itemViewed` | `product_viewed` | With `product`. |
|
|
539
|
+
| `addedToCart` | `product_added_to_cart` | One per changed line, with the quantity added. Leave `getCartId` out when Hydrogen's `useCartAnalytics()` tracks the cart, or adds count twice. |
|
|
540
|
+
|
|
541
|
+
Shopify's script drops products without `id`, `title`, `vendor`, `variantId` or `price.amount`, so incomplete products are left out with a development warning; numeric ids become GIDs. Events delivered before `<ShopifyScripts>` sets up the bus are held (at most 100, for 30 seconds after each).
|
|
542
|
+
|
|
543
|
+
`ShopifyEvents` also has `search_viewed`, `collection_viewed` and Hydrogen's cart events. Spread the mapping to add them:
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
withConsent(
|
|
547
|
+
map(shopifyDestination(), {
|
|
548
|
+
...shopifyMapping<Events>({ product, cartProduct, getCartId }),
|
|
549
|
+
searched: ({ term }, { context }) => ({
|
|
550
|
+
name: "search_viewed",
|
|
551
|
+
payload: { url: context.url, searchTerm: term },
|
|
552
|
+
}),
|
|
553
|
+
collectionViewed: (collection, { context }) => ({
|
|
554
|
+
name: "collection_viewed",
|
|
555
|
+
payload: { url: context.url, collection },
|
|
556
|
+
}),
|
|
557
|
+
}),
|
|
558
|
+
["analytics"],
|
|
559
|
+
);
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
## URLs and personal data
|
|
563
|
+
|
|
564
|
+
URLs can carry tokens, order keys or emails, and every event has the page URL in `context.url`. Analytics doesn't clean them, as the parameters differ per project and tags read the page URL themselves anyway. Instead:
|
|
565
|
+
|
|
566
|
+
1. **Don't track links with tokens:** leave them out of auto-tracked areas, or wrap them in `track({ auto: false })`.
|
|
567
|
+
2. **Turn on GA4's data redaction** (Admin → Data streams → Redact data) for emails and parameters like `token`, `key` and `email`. Keep `utm_*` and `gclid` off the list.
|
|
568
|
+
3. **Clean what GTM receives**, when other tags need it too, by wrapping the destination's subscriptions:
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
const SENSITIVE_PARAMS = ["token", "key", "email"];
|
|
572
|
+
const URL_KEYS = [
|
|
573
|
+
"page_location",
|
|
574
|
+
"page_referrer",
|
|
575
|
+
"url",
|
|
576
|
+
"video_url",
|
|
577
|
+
"form_destination",
|
|
578
|
+
];
|
|
579
|
+
|
|
580
|
+
const redact = (params: Record<string, unknown>) => {
|
|
581
|
+
for (const key of URL_KEYS) {
|
|
582
|
+
const value = params[key];
|
|
583
|
+
if (typeof value !== "string" || !value) continue;
|
|
584
|
+
const url = new URL(value, window.location.href);
|
|
585
|
+
SENSITIVE_PARAMS.forEach((param) => url.searchParams.delete(param));
|
|
586
|
+
params[key] = url.href;
|
|
587
|
+
}
|
|
588
|
+
// `custom` events hold theirs under `params`.
|
|
589
|
+
if (params.params) redact(params.params as Record<string, unknown>);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const gtm = gtmDestination();
|
|
593
|
+
|
|
594
|
+
withConsent(
|
|
595
|
+
map(
|
|
596
|
+
{
|
|
597
|
+
...gtm,
|
|
598
|
+
setup: ({ subscribe }) =>
|
|
599
|
+
gtm.setup({
|
|
600
|
+
subscribe: (event, callback) =>
|
|
601
|
+
subscribe(event, (payload, context) => {
|
|
602
|
+
redact(payload as Record<string, unknown>);
|
|
603
|
+
callback(payload, context);
|
|
604
|
+
}),
|
|
605
|
+
}),
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
...mapEvery(events, toGa4CustomEvent),
|
|
609
|
+
...ga4Mapping<Events>({ item: toGa4Item }),
|
|
610
|
+
},
|
|
611
|
+
),
|
|
612
|
+
"none",
|
|
613
|
+
);
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
This only changes the dataLayer: tags that read the page URL themselves still see the original.
|
|
617
|
+
|
|
618
|
+
## Writing a destination
|
|
619
|
+
|
|
620
|
+
A destination is typed by the events it accepts, shaped like Hydrogen's `StorefrontAnalyticsDestination`:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
type ConsoleEvents = {
|
|
624
|
+
view: { path: string };
|
|
625
|
+
order: { id: string; total: number };
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
export const consoleDestination: AnalyticsDestination<ConsoleEvents> = {
|
|
629
|
+
name: "console",
|
|
630
|
+
setup: ({ subscribe }) => {
|
|
631
|
+
subscribe("view", ({ path }) => console.log("view", path));
|
|
632
|
+
subscribe("order", (order, { id, context, consent }) => {
|
|
633
|
+
console.log("order", order.total, id, context, consent);
|
|
634
|
+
});
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
- **`name`** prefixes its errors and warnings.
|
|
640
|
+
- **`setup`** runs once in the browser, before consent. Subscribe to every event it accepts, and list them in `events` if it takes your own events without `map()`, so `routes()` knows them; in development, a mapping that sends an unsubscribed event warns. Don't load the provider's script here, but with the first event, which only arrives once allowed. It can be async; events wait for it.
|
|
641
|
+
- **Callbacks** get the payload and `{ id, context, consent }`. `id` is unique per event, so providers with server-side events (Meta CAPI, GA4 Measurement Protocol) can dedupe.
|
|
642
|
+
- **What it accepts is a minimum:** required fields are what the provider needs. Keep open shapes, like GTM's `Record<string, unknown>`, for providers that take anything.
|
|
643
|
+
- **Consent is set with `withConsent()`**, so name the categories it needs in its docs. Telling the provider consent is a [consent target](#consent-targets)'s job.
|
|
644
|
+
- **Ship a builder**, like `ga4Mapping()`, generic over `Events` with adapters typed from it, e.g. `Events["itemViewed"]["item"]`, returning `AnalyticsMapping<Events, YourEvents>`. A one-project destination can type its mapping by `Events` directly.
|
|
645
|
+
- **Errors are isolated:** a failing `setup` is logged and the destination receives nothing; a failing callback is logged and the others still run.
|
|
646
|
+
|
|
647
|
+
## Recipes
|
|
648
|
+
|
|
649
|
+
### Next.js App Router
|
|
650
|
+
|
|
651
|
+
```
|
|
652
|
+
src/
|
|
653
|
+
analytics/event-catalog.ts events = defineEvents({ … }), Events = EventsOf<typeof events>
|
|
654
|
+
analytics/tracking.ts domWatcher = createDomWatcher([…]), { track } = domWatcher,
|
|
655
|
+
cartChanges = createCartChanges<CartItem>()
|
|
656
|
+
analytics/index.ts analytics = createAnalytics<Events>({ …, sources: [domWatcher, cartChanges] })
|
|
657
|
+
app/layout.tsx the consent platform's script, <GoogleTagManager />
|
|
658
|
+
app/**/page.tsx <main {...track({ pageView: { pageType: "…" } })}>
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Server and client components import `track()` and `cartChanges` from `analytics/tracking.ts`; only client components import `analytics/index.ts`. As `createAnalytics()` starts the trackers, a client component every page renders, e.g. in the layout, must import it.
|
|
662
|
+
|
|
663
|
+
### Multi-page sites
|
|
664
|
+
|
|
665
|
+
Analytics needs no framework, so it also runs on sites whose server renders every page and links load them in full, e.g. a CMS's templates. Bundle the same modules into one script, e.g. with Vite or esbuild:
|
|
666
|
+
|
|
667
|
+
```
|
|
668
|
+
src/analytics/
|
|
669
|
+
event-catalog.ts as in Next.js
|
|
670
|
+
tracking.ts as in Next.js
|
|
671
|
+
index.ts analytics, <track-data>, the cart, bundled to analytics.js
|
|
672
|
+
templates/
|
|
673
|
+
layout the consent platform's script, GTM, the starting cart, <script src="/analytics.js" defer>
|
|
674
|
+
pages, partials page views and clicks as attributes; products, lists and promotions in <track-data>
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**Attribute types** are written as attributes, e.g. on each page's root, the header and the footer:
|
|
678
|
+
|
|
679
|
+
```html
|
|
680
|
+
<header data-track="auto-links location" data-track-name="header">…</header>
|
|
681
|
+
<main data-track="page-view auto-links" data-track-page-type="product">…</main>
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
**Data types** need a `ref`, so the server renders the data as JSON and a custom element sets it. Markup replaced later, e.g. by a partial reload or infinite scroll, sets its own when connected:
|
|
685
|
+
|
|
686
|
+
```html
|
|
687
|
+
<track-data type="list">
|
|
688
|
+
<script type="application/json">
|
|
689
|
+
{ "id": "summer", "name": "Summer" }
|
|
690
|
+
</script>
|
|
691
|
+
<ul>
|
|
692
|
+
<li>
|
|
693
|
+
<track-data type="item">
|
|
694
|
+
<script type="application/json">
|
|
695
|
+
{
|
|
696
|
+
"id": "tee",
|
|
697
|
+
"title": "Tee",
|
|
698
|
+
"variant": "M",
|
|
699
|
+
"price": 29.95,
|
|
700
|
+
"currency": "EUR"
|
|
701
|
+
}
|
|
702
|
+
</script>
|
|
703
|
+
<!-- the product card -->
|
|
704
|
+
</track-data>
|
|
705
|
+
</li>
|
|
706
|
+
</ul>
|
|
707
|
+
</track-data>
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
```ts
|
|
711
|
+
// src/analytics/index.ts
|
|
712
|
+
const refs = {
|
|
713
|
+
"view-item": (data: Item) => track({ viewItem: data }).ref,
|
|
714
|
+
item: (data: Item) => track({ item: data }).ref,
|
|
715
|
+
list: (data: List) => track({ list: data }).ref,
|
|
716
|
+
promotion: (data: Promotion) => track({ promotion: data }).ref,
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
customElements.define(
|
|
720
|
+
"track-data",
|
|
721
|
+
class extends HTMLElement {
|
|
722
|
+
connectedCallback() {
|
|
723
|
+
const type = this.getAttribute("type") as keyof typeof refs;
|
|
724
|
+
const json = this.querySelector(
|
|
725
|
+
":scope > script[type='application/json']",
|
|
726
|
+
);
|
|
727
|
+
if (refs[type] && json?.textContent) {
|
|
728
|
+
refs[type](JSON.parse(json.textContent))(this);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
);
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
- **Give it a box:** `track-data { display: block; }`, as custom elements are inline and product views need a box.
|
|
736
|
+
- **Escape the JSON** as your template language does for `<script>` content, so a `</script>` in a product name can't end it.
|
|
737
|
+
- **Keep the JSON lean:** what the mappings read, not the whole product.
|
|
738
|
+
- **Variant pickers** that swap the variant without re-rendering the `view-item` element leave its JSON on the first variant: re-render it, or set the new data with `refs["view-item"](data)(element)`.
|
|
739
|
+
- **Placement across pages:** a product page loaded from a list card gets the card's list and index from the tab's `sessionStorage` ([Placement](tracking.md#behaviour)), as long as `ecommerce()`'s `id` gives both the same id.
|
|
740
|
+
|
|
741
|
+
**The cart:** render the cart the page starts with, then pass `cartChanges` every cart the server confirms after a change, as a [`CartSnapshot`](tracking.md#cart):
|
|
742
|
+
|
|
743
|
+
```html
|
|
744
|
+
<script type="application/json" id="analytics-cart">
|
|
745
|
+
{ "id": "…", "currency": "EUR", "lines": [ … ] }
|
|
746
|
+
</script>
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
const starting = document.getElementById("analytics-cart")?.textContent;
|
|
751
|
+
if (starting) cartChanges.setCart(JSON.parse(starting));
|
|
752
|
+
|
|
753
|
+
// Wherever the site's cart code receives the updated cart, with your own `toCartSnapshot()`.
|
|
754
|
+
cartChanges.setCart(toCartSnapshot(await response.json()));
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
A form that adds to the cart and redirects needs nothing more: the next page's starting cart differs from the one stored.
|
|
758
|
+
|
|
759
|
+
**Keep in mind:**
|
|
760
|
+
|
|
761
|
+
- **Events waiting for consent** are kept in memory, so those of a page the visitor leaves before answering the banner are lost.
|
|
762
|
+
- **Load GTM after the consent platform**, as in Next.js. With the `googleConsentMode()` target, load GTM from the bundle after `createAnalytics()`, so the denied defaults come first.
|
|
763
|
+
|
|
764
|
+
#### Shopify Liquid themes
|
|
765
|
+
|
|
766
|
+
On a Liquid theme, Shopify's defaults are usually enough, without this library: Shopify sends its standard events (page, product, cart and checkout) to Shopify Analytics and to web pixels itself, and the [Google & YouTube sales channel](https://apps.shopify.com/google) sends them to GA4 as ecommerce events, purchases included, respecting the visitor's consent. Turn on its GA4 connection rather than tracking the same events from GTM, or GA4 counts them twice.
|
|
767
|
+
|
|
768
|
+
### Ecommerce flow
|
|
769
|
+
|
|
770
|
+
`track()` on lists, items and the product page covers views and selections, and `createCartChanges()` adds and removes. The rest is your own events:
|
|
771
|
+
|
|
772
|
+
```ts
|
|
773
|
+
export const events = defineEvents({
|
|
774
|
+
...domWatcher.events,
|
|
775
|
+
...cartChanges.events,
|
|
776
|
+
checkoutStarted: {
|
|
777
|
+
description: "The checkout page was opened with the cart's lines.",
|
|
778
|
+
payload: shape<{ cart: CartFragment }>(),
|
|
779
|
+
},
|
|
780
|
+
orderPlaced: {
|
|
781
|
+
description: "An order was placed, on the thank-you page. Once per order.",
|
|
782
|
+
payload: shape<{ order: OrderFragment }>(),
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
- **Purchases count once:** a reload of the thank-you page tracks again, so remember the order id, e.g. in `sessionStorage`.
|
|
788
|
+
|
|
789
|
+
### A Meta Pixel destination
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
// src/analytics/meta-destination.ts
|
|
793
|
+
type Fbq = ((...args: unknown[]) => void) & { queue?: unknown[] };
|
|
794
|
+
declare global {
|
|
795
|
+
interface Window {
|
|
796
|
+
fbq?: Fbq;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const loadPixel = () => {
|
|
801
|
+
const queue: unknown[] = [];
|
|
802
|
+
const fbq: Fbq = (...args) => queue.push(args);
|
|
803
|
+
fbq.queue = queue;
|
|
804
|
+
window.fbq = fbq;
|
|
805
|
+
const script = document.createElement("script");
|
|
806
|
+
script.async = true;
|
|
807
|
+
script.src = "https://connect.facebook.net/en_US/fbevents.js";
|
|
808
|
+
document.head.append(script);
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
type Content = {
|
|
812
|
+
content_ids: string[];
|
|
813
|
+
content_type: "product";
|
|
814
|
+
currency: string;
|
|
815
|
+
value: number;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
export type MetaEvents = {
|
|
819
|
+
PageView: Record<never, never>;
|
|
820
|
+
ViewContent: Content;
|
|
821
|
+
AddToCart: Content;
|
|
822
|
+
Purchase: Content;
|
|
823
|
+
Lead: { content_name?: string };
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
export const metaDestination = (
|
|
827
|
+
pixelId: string,
|
|
828
|
+
): AnalyticsDestination<MetaEvents> => {
|
|
829
|
+
const fbq = () => {
|
|
830
|
+
if (!window.fbq) {
|
|
831
|
+
loadPixel();
|
|
832
|
+
window.fbq!("init", pixelId);
|
|
833
|
+
}
|
|
834
|
+
return window.fbq!;
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
name: "meta",
|
|
839
|
+
setup: ({ subscribe }) => {
|
|
840
|
+
for (const name of [
|
|
841
|
+
"PageView",
|
|
842
|
+
"ViewContent",
|
|
843
|
+
"AddToCart",
|
|
844
|
+
"Purchase",
|
|
845
|
+
"Lead",
|
|
846
|
+
] as const) {
|
|
847
|
+
// `eventID` lets Meta dedupe against the Conversions API.
|
|
848
|
+
subscribe(name, (payload, { id }) =>
|
|
849
|
+
fbq()("track", name, payload, { eventID: id }),
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
};
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
Map it as in [Destinations and mappings](#destinations-and-mappings), with `withConsent(…, ["marketing"])`, so the script only loads with marketing consent, and add [`metaConsent`](#consent-targets) as a target, as the script can't be unloaded when consent is revoked. For advanced matching, pass `user.email` and `user.phone` as `{ em, ph }` to `fbq("init", …)`.
|
|
858
|
+
|
|
859
|
+
### Logged-in users
|
|
860
|
+
|
|
861
|
+
Set the user before tracking `loggedIn`, so the event carries it, and restore it before the first `pageViewed` when a session exists. Use the platform's customer id, never the email.
|
|
862
|
+
|
|
863
|
+
```ts
|
|
864
|
+
analytics.setContext({
|
|
865
|
+
user: {
|
|
866
|
+
id: customer.id,
|
|
867
|
+
email: customer.email,
|
|
868
|
+
type: customer.ordersCount > 0 ? "returning" : "new",
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
analytics.track("loggedIn", { method: "email" });
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Map it to GA4's `login` in GTM's mapping: `loggedIn: (payload) => ({ name: "login", payload })`. Without it, `mapEvery(events, toGa4CustomEvent)` sends it as `logged_in`.
|
|
875
|
+
|
|
876
|
+
### Debugging
|
|
877
|
+
|
|
878
|
+
- **Log every event** in development, also those queued for consent: `analytics.subscribe((event) => console.debug("[analytics]", event))`.
|
|
879
|
+
- **See what GTM receives:** `dataLayer` in the console, or GTM's Preview mode, which also shows the consent state.
|
|
880
|
+
- **A destination gets nothing?** Its `requires` isn't granted (check `Cookiebot.consent` or `OnetrustActiveGroups`), its mapping has no entry, or it threw: errors are logged as `[analytics] <name>.<event>`. `analytics.routes()` shows what reaches it.
|
|
881
|
+
- **`"…" reach no destination`:** no mapping covers these catalog events. Map them, or remove them.
|
|
882
|
+
- **`"…" isn't in the events passed to createAnalytics()`:** `track()` got a name outside the catalog. Add it with `defineEvents()`.
|
|
883
|
+
- **A tracker's event missing?** Check its tracker is in `sources`, then [tracking's debugging](tracking.md#debugging).
|