@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.1
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/LICENSE +21 -0
- package/README.md +825 -2
- package/dist/hp.min.js +3 -0
- package/dist/snippet.min.js +1 -0
- package/package.json +83 -3
- package/src/assignment.js +75 -0
- package/src/auto/attributes.js +16 -0
- package/src/auto/exposures.js +45 -0
- package/src/auto/lists.js +136 -0
- package/src/auto/promotions.js +104 -0
- package/src/auto/rails.js +217 -0
- package/src/auto/viewability.js +150 -0
- package/src/auto.js +13 -0
- package/src/bootstrap.js +476 -0
- package/src/carry.js +74 -0
- package/src/conformance.js +530 -0
- package/src/consent-gate.js +324 -0
- package/src/consent.js +604 -0
- package/src/context.js +439 -0
- package/src/datalayer.js +411 -0
- package/src/decisions.js +90 -0
- package/src/device-storage.js +201 -0
- package/src/experiments.js +220 -0
- package/src/ga4.js +124 -0
- package/src/hash.js +138 -0
- package/src/hp.js +135 -0
- package/src/index.js +818 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/segment.js +375 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +163 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +146 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +211 -0
- package/src/shape.js +111 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +159 -0
- package/src/transport.js +436 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +154 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +680 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +610 -0
- package/types/segment.d.ts +68 -0
package/src/datalayer.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GA4 dataLayer -> CDP events.
|
|
3
|
+
*
|
|
4
|
+
* const disconnect = connectDataLayer(analytics);
|
|
5
|
+
*
|
|
6
|
+
* The core knows the GA4 standard only: `{ event: 'view_item', ecommerce: { items, currency,
|
|
7
|
+
* value } }` as GTM documents it, parameters at the top level for non-ecommerce events
|
|
8
|
+
* (`{ event: 'login', method }`), and gtag's `['event', name, params]`. A platform with its own
|
|
9
|
+
* conventions is taught them by a preset, which the host passes in: `eventName`, `transform` and
|
|
10
|
+
* `productId` normalise its pushes into that standard, and `map` adds the events GA4 has no name
|
|
11
|
+
* for.
|
|
12
|
+
*
|
|
13
|
+
* Works with this package's client and with an analytics-next instance: the only call it makes is
|
|
14
|
+
* `analytics.track(event, properties, { context })`, plus `analytics.signedOut()` when a mapping
|
|
15
|
+
* emits Signed Out and the client has one (so identity is cleared after the event, not before).
|
|
16
|
+
*
|
|
17
|
+
* `purchase` and `refund` are never mapped: orders are reported by the service that took them,
|
|
18
|
+
* which sees every order exactly once. A browser copy would be a second system of record.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { currencyCode } from './context.js';
|
|
22
|
+
import { defaultProductId, toProducts } from './ga4.js';
|
|
23
|
+
|
|
24
|
+
/** Re-exported for presets: the first usable id among the candidates, as a string, or null. */
|
|
25
|
+
export { firstId } from './ga4.js';
|
|
26
|
+
|
|
27
|
+
const DEDUPE_MS = 1000;
|
|
28
|
+
const DEFAULT_STEPS = { shipping: 2, payment: 3 };
|
|
29
|
+
const SIGN_IN_METHODS = new Set([
|
|
30
|
+
'password',
|
|
31
|
+
'google',
|
|
32
|
+
'apple',
|
|
33
|
+
'facebook',
|
|
34
|
+
'magic_link',
|
|
35
|
+
'sso',
|
|
36
|
+
'passkey',
|
|
37
|
+
'other',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const text = (v) => {
|
|
41
|
+
if (v == null || typeof v === 'object') return null;
|
|
42
|
+
const s = String(v).trim();
|
|
43
|
+
return s === '' ? null : s;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const amount = (v) => {
|
|
47
|
+
if (v == null || v === '' || typeof v === 'object') return null;
|
|
48
|
+
const n = Number(v);
|
|
49
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isObject = (v) => v !== null && typeof v === 'object';
|
|
53
|
+
|
|
54
|
+
/** Null when the push carried no item list at all, which is not the same as an empty one. */
|
|
55
|
+
function readItems(ecommerce) {
|
|
56
|
+
if (!Array.isArray(ecommerce.items)) return null;
|
|
57
|
+
return ecommerce.items.filter(isObject);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function signInMethod(value) {
|
|
61
|
+
const method = text(value)
|
|
62
|
+
?.toLowerCase()
|
|
63
|
+
.replace(/[\s-]+/g, '_');
|
|
64
|
+
if (!method) return null;
|
|
65
|
+
if (SIGN_IN_METHODS.has(method)) return method;
|
|
66
|
+
if (method === 'email' || method === 'email_password') return 'password';
|
|
67
|
+
return 'other';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Promotion fields live on ecommerce, or on each item (GA4 allows both). One per promotion id. */
|
|
71
|
+
function promotionsOf({ ecommerce, items }) {
|
|
72
|
+
const read = (source) => ({
|
|
73
|
+
promotion_id: text(source.promotion_id),
|
|
74
|
+
name: text(source.promotion_name),
|
|
75
|
+
creative: text(source.creative_name),
|
|
76
|
+
// GA4's creative_slot is Segment's `position`, which spec 1.2 restored.
|
|
77
|
+
position: text(source.creative_slot),
|
|
78
|
+
});
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const promotion of [read(ecommerce), ...items.map(read)]) {
|
|
82
|
+
// Never invent identity: a name is not an id, and an impression nobody can join is noise.
|
|
83
|
+
if (!promotion.promotion_id || seen.has(promotion.promotion_id)) continue;
|
|
84
|
+
seen.add(promotion.promotion_id);
|
|
85
|
+
out.push(promotion);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const listId = ({ ecommerce, items }) =>
|
|
91
|
+
text(ecommerce.item_list_id) ??
|
|
92
|
+
text(ecommerce.item_list_name) ??
|
|
93
|
+
text(items[0]?.item_list_id) ??
|
|
94
|
+
text(items[0]?.item_list_name);
|
|
95
|
+
|
|
96
|
+
const needProducts = (event, properties) =>
|
|
97
|
+
properties.products?.length ? { event, properties } : null;
|
|
98
|
+
|
|
99
|
+
/** GA4 `share` names what was shared by content_type; only a product maps onto Product Shared. */
|
|
100
|
+
const SHARED_PRODUCT_TYPES = new Set(['product', 'products', 'item', 'items']);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* GA4's recommended `share` event ({ method, content_type, item_id }) -> Product Shared.
|
|
104
|
+
*
|
|
105
|
+
* `item_id` is the shared content's id, not a GA4 item, so it is taken as the product id as it
|
|
106
|
+
* stands. A share of anything other than a product (an article, a video) is not an ecommerce
|
|
107
|
+
* event, and GA4 has no basket share, so there is nothing here for Cart Shared.
|
|
108
|
+
*/
|
|
109
|
+
function productShared({ ecommerce }) {
|
|
110
|
+
const productId = text(ecommerce.item_id);
|
|
111
|
+
const contentType = text(ecommerce.content_type)?.toLowerCase();
|
|
112
|
+
if (!productId || (contentType && !SHARED_PRODUCT_TYPES.has(contentType)))
|
|
113
|
+
return null;
|
|
114
|
+
return {
|
|
115
|
+
event: 'Product Shared',
|
|
116
|
+
properties: {
|
|
117
|
+
share_via: text(ecommerce.method),
|
|
118
|
+
products: [{ product_id: productId }],
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* GA4's recommended ecommerce and account events -> spec events. Each builder returns only fields
|
|
125
|
+
* the target schema allows, or null when the push cannot make a valid event.
|
|
126
|
+
*
|
|
127
|
+
* GA4 has no coupon events (`coupon` is a parameter on the commerce events, carried through
|
|
128
|
+
* above), no wishlist removal or wishlist-to-basket event, and no basket share, so Coupon Entered,
|
|
129
|
+
* Applied, Denied and Removed, Product Removed from Wishlist, Wishlist Product Added to Cart and
|
|
130
|
+
* Cart Shared have no default mapping. A preset whose platform pushes them adds one through `map`.
|
|
131
|
+
*/
|
|
132
|
+
function defaultMappings(steps) {
|
|
133
|
+
const listViewed = (p) =>
|
|
134
|
+
needProducts('Product List Viewed', {
|
|
135
|
+
list_id: listId(p),
|
|
136
|
+
products: p.products,
|
|
137
|
+
});
|
|
138
|
+
const searched = (p) => {
|
|
139
|
+
const query = text(p.ecommerce.search_term);
|
|
140
|
+
if (!query) return null;
|
|
141
|
+
return {
|
|
142
|
+
event: 'Products Searched',
|
|
143
|
+
properties: {
|
|
144
|
+
query,
|
|
145
|
+
...(p.products.length ? { products: p.products } : {}),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
const promotion = (event) => (p) =>
|
|
150
|
+
p.promotions.map((fields) => ({ event, properties: fields }));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
view_item: (p) =>
|
|
154
|
+
needProducts('Product Viewed', {
|
|
155
|
+
currency: p.currency,
|
|
156
|
+
value: p.value,
|
|
157
|
+
products: p.products.slice(0, 1),
|
|
158
|
+
}),
|
|
159
|
+
view_item_list: listViewed,
|
|
160
|
+
view_search_results: (p) => searched(p) ?? listViewed(p),
|
|
161
|
+
search: searched,
|
|
162
|
+
select_item: (p) =>
|
|
163
|
+
needProducts('Product Clicked', {
|
|
164
|
+
list_id: listId(p),
|
|
165
|
+
products: p.products.slice(0, 1),
|
|
166
|
+
}),
|
|
167
|
+
add_to_cart: (p) =>
|
|
168
|
+
needProducts('Product Added', {
|
|
169
|
+
cart_id: text(p.ecommerce.cart_id),
|
|
170
|
+
products: p.products,
|
|
171
|
+
}),
|
|
172
|
+
remove_from_cart: (p) =>
|
|
173
|
+
needProducts('Product Removed', {
|
|
174
|
+
cart_id: text(p.ecommerce.cart_id),
|
|
175
|
+
products: p.products,
|
|
176
|
+
}),
|
|
177
|
+
view_cart: (p) => ({
|
|
178
|
+
event: 'Cart Viewed',
|
|
179
|
+
properties: {
|
|
180
|
+
cart_id: text(p.ecommerce.cart_id),
|
|
181
|
+
value: p.value,
|
|
182
|
+
currency: p.currency,
|
|
183
|
+
coupon: text(p.ecommerce.coupon),
|
|
184
|
+
// A view_cart without items says nothing about the basket; [] would claim it was empty.
|
|
185
|
+
...(p.itemsListed ? { products: p.products } : {}),
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
begin_checkout: (p) =>
|
|
189
|
+
needProducts('Checkout Started', {
|
|
190
|
+
value: p.value,
|
|
191
|
+
currency: p.currency,
|
|
192
|
+
coupon: text(p.ecommerce.coupon),
|
|
193
|
+
products: p.products,
|
|
194
|
+
}),
|
|
195
|
+
add_shipping_info: (p) => ({
|
|
196
|
+
event: 'Checkout Step Completed',
|
|
197
|
+
properties: {
|
|
198
|
+
step: steps.shipping,
|
|
199
|
+
step_name: 'shipping',
|
|
200
|
+
shipping_method: text(p.ecommerce.shipping_tier),
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
add_payment_info: (p) => ({
|
|
204
|
+
event: 'Payment Info Entered',
|
|
205
|
+
properties: {
|
|
206
|
+
step: steps.payment,
|
|
207
|
+
step_name: 'payment',
|
|
208
|
+
payment_method: text(p.ecommerce.payment_type),
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
view_promotion: promotion('Promotion Viewed'),
|
|
212
|
+
select_promotion: promotion('Promotion Clicked'),
|
|
213
|
+
add_to_wishlist: (p) =>
|
|
214
|
+
needProducts('Product Added to Wishlist', { products: p.products }),
|
|
215
|
+
share: productShared,
|
|
216
|
+
login: (p) => ({
|
|
217
|
+
event: 'Signed In',
|
|
218
|
+
properties: { method: signInMethod(p.ecommerce.method) },
|
|
219
|
+
}),
|
|
220
|
+
sign_up: (p) => ({
|
|
221
|
+
event: 'Signed Up',
|
|
222
|
+
properties: { method: signInMethod(p.ecommerce.method) },
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const defaultEventName = (entry) => entry.event;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* An entry as { name, entry, ecommerce }: gtag's `['event', name, params]`, or a dataLayer object
|
|
231
|
+
* whose ecommerce block, when it has none, is its own top level (GTM puts event parameters there).
|
|
232
|
+
*/
|
|
233
|
+
function readEntry(entry, eventName) {
|
|
234
|
+
if (!isObject(entry)) return null;
|
|
235
|
+
if (
|
|
236
|
+
typeof entry.length === 'number' &&
|
|
237
|
+
entry[0] === 'event' &&
|
|
238
|
+
typeof entry[1] === 'string'
|
|
239
|
+
) {
|
|
240
|
+
const params = isObject(entry[2]) ? entry[2] : {};
|
|
241
|
+
return { name: entry[1], entry: params, ecommerce: params };
|
|
242
|
+
}
|
|
243
|
+
const name = text(eventName(entry));
|
|
244
|
+
if (!name) return null;
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
entry,
|
|
248
|
+
ecommerce: isObject(entry.ecommerce) ? entry.ecommerce : entry,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** JSON with object keys sorted, so two equal payloads always serialise identically. */
|
|
253
|
+
function stableJson(value) {
|
|
254
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
255
|
+
if (value && typeof value === 'object') {
|
|
256
|
+
return `{${Object.keys(value)
|
|
257
|
+
.sort()
|
|
258
|
+
.map((k) => `${JSON.stringify(k)}:${stableJson(value[k])}`)
|
|
259
|
+
.join(',')}}`;
|
|
260
|
+
}
|
|
261
|
+
return JSON.stringify(value ?? null);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {{ track: Function, signedOut?: Function }} analytics
|
|
266
|
+
* @param {object} [options]
|
|
267
|
+
* @param {unknown[]} [options.dataLayer] Defaults to `window.dataLayer`, created if absent.
|
|
268
|
+
* @param {(entry: object) => string|null|undefined} [options.eventName] The GA4 event name of a
|
|
269
|
+
* push. Default `entry.event`.
|
|
270
|
+
* @param {(entry: unknown) => unknown} [options.transform] Rewrites a push before anything else
|
|
271
|
+
* reads it; return null to skip it.
|
|
272
|
+
* @param {(item: object) => unknown} [options.productId] Parent product id of a GA4 item.
|
|
273
|
+
* Default `item.item_group_id ?? item.item_id`.
|
|
274
|
+
* @param {Record<string, (payload: object, helpers: object) => object | object[] | null>} [options.map]
|
|
275
|
+
* Add or override mappings, keyed by GA4 event name. `payload` is the (transformed) entry.
|
|
276
|
+
* @param {string[]} [options.ignore] GA4 event names to skip.
|
|
277
|
+
* @param {{ shipping?: number, payment?: number }} [options.steps] Checkout step numbers.
|
|
278
|
+
* @param {number} [options.dedupeMs] Window in which an identical push is emitted once.
|
|
279
|
+
* @param {(error: unknown, entry: unknown) => void} [options.onError]
|
|
280
|
+
* @returns {() => void} disconnect
|
|
281
|
+
*/
|
|
282
|
+
export function connectDataLayer(analytics, options = {}) {
|
|
283
|
+
if (!analytics || typeof analytics.track !== 'function') {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'connectDataLayer: analytics must have a track(event, properties, options) method',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const dataLayer =
|
|
289
|
+
options.dataLayer ??
|
|
290
|
+
(typeof window !== 'undefined'
|
|
291
|
+
? (window.dataLayer = window.dataLayer || [])
|
|
292
|
+
: null);
|
|
293
|
+
if (!Array.isArray(dataLayer)) return () => {};
|
|
294
|
+
|
|
295
|
+
const defaults = defaultMappings({ ...DEFAULT_STEPS, ...options.steps });
|
|
296
|
+
const custom = options.map ?? {};
|
|
297
|
+
const eventName = options.eventName ?? defaultEventName;
|
|
298
|
+
const productId = options.productId ?? defaultProductId;
|
|
299
|
+
const ignore = new Set(['purchase', 'refund', ...(options.ignore ?? [])]);
|
|
300
|
+
const dedupeMs = options.dedupeMs ?? DEDUPE_MS;
|
|
301
|
+
const recent = new Map();
|
|
302
|
+
let connected = true;
|
|
303
|
+
|
|
304
|
+
// Duplicate means the same event with the same properties, in the same currency: legacy trackers
|
|
305
|
+
// push some events twice, and item-less events (two filter changes) must stay distinct.
|
|
306
|
+
function isDuplicate(event, properties, currency) {
|
|
307
|
+
if (dedupeMs <= 0) return false;
|
|
308
|
+
const key = `${event}|${currency ?? ''}|${stableJson(properties)}`;
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
for (const [k, at] of recent) if (now - at > dedupeMs) recent.delete(k);
|
|
311
|
+
if (recent.has(key)) return true;
|
|
312
|
+
recent.set(key, now);
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A product's details are viewed once per page. GA4 storefronts commonly push view_item again
|
|
317
|
+
// when a size or colour is picked, which is a variant change, not another view. The page is the
|
|
318
|
+
// path: a variant is often carried in the query string.
|
|
319
|
+
let viewedPage = null;
|
|
320
|
+
const viewedProducts = new Set();
|
|
321
|
+
function isRepeatView(event, properties) {
|
|
322
|
+
if (event !== 'Product Viewed') return false;
|
|
323
|
+
const page = typeof location === 'undefined' ? '' : location.pathname;
|
|
324
|
+
if (page !== viewedPage) {
|
|
325
|
+
viewedPage = page;
|
|
326
|
+
viewedProducts.clear();
|
|
327
|
+
}
|
|
328
|
+
const productId = properties.products?.[0]?.product_id;
|
|
329
|
+
if (!productId) return false;
|
|
330
|
+
if (viewedProducts.has(productId)) return true;
|
|
331
|
+
viewedProducts.add(productId);
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A push's prices are in the push's currency. Events whose schema has `currency` carry it as a
|
|
336
|
+
// property; every event also gets it as a context override, so the site's current currency can
|
|
337
|
+
// never be stamped onto a price that was pushed in another.
|
|
338
|
+
function send({ event, properties = {} }, currency) {
|
|
339
|
+
if (
|
|
340
|
+
!event ||
|
|
341
|
+
isDuplicate(event, properties, currency) ||
|
|
342
|
+
isRepeatView(event, properties)
|
|
343
|
+
) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const callOptions = currency ? { context: { currency } } : undefined;
|
|
347
|
+
if (event === 'Signed Out' && typeof analytics.signedOut === 'function') {
|
|
348
|
+
analytics.signedOut(callOptions);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
analytics.track(event, properties, callOptions);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function handle(raw) {
|
|
355
|
+
const entry = options.transform ? options.transform(raw) : raw;
|
|
356
|
+
const read = readEntry(entry, eventName);
|
|
357
|
+
if (!read || ignore.has(read.name)) return;
|
|
358
|
+
const isCustom = Object.hasOwn(custom, read.name);
|
|
359
|
+
const mapping = isCustom ? custom[read.name] : defaults[read.name];
|
|
360
|
+
if (typeof mapping !== 'function') return;
|
|
361
|
+
|
|
362
|
+
const listed = readItems(read.ecommerce);
|
|
363
|
+
const items = listed ?? [];
|
|
364
|
+
const payload = {
|
|
365
|
+
...read,
|
|
366
|
+
items,
|
|
367
|
+
itemsListed: listed !== null,
|
|
368
|
+
products: toProducts(items, productId),
|
|
369
|
+
currency: currencyCode(read.ecommerce.currency),
|
|
370
|
+
value: amount(read.ecommerce.value),
|
|
371
|
+
};
|
|
372
|
+
payload.promotions = promotionsOf(payload);
|
|
373
|
+
|
|
374
|
+
const result = isCustom
|
|
375
|
+
? mapping(entry, {
|
|
376
|
+
...payload,
|
|
377
|
+
toProducts: (list) => toProducts(list, productId),
|
|
378
|
+
})
|
|
379
|
+
: mapping(payload);
|
|
380
|
+
for (const out of [result].flat()) if (out) send(out, payload.currency);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function safely(entry) {
|
|
384
|
+
if (!connected) return;
|
|
385
|
+
try {
|
|
386
|
+
handle(entry);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
try {
|
|
389
|
+
options.onError?.(err, entry);
|
|
390
|
+
} catch {
|
|
391
|
+
/* never throw into the page */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const entry of dataLayer.slice()) safely(entry);
|
|
397
|
+
|
|
398
|
+
// Wrap, never replace: GTM wraps push too, and whichever of us loads second must call through.
|
|
399
|
+
const previousPush = dataLayer.push;
|
|
400
|
+
function push(...entries) {
|
|
401
|
+
const result = previousPush.apply(this, entries);
|
|
402
|
+
for (const entry of entries) safely(entry);
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
dataLayer.push = push;
|
|
406
|
+
|
|
407
|
+
return () => {
|
|
408
|
+
connected = false;
|
|
409
|
+
if (dataLayer.push === push) dataLayer.push = previousPush;
|
|
410
|
+
};
|
|
411
|
+
}
|
package/src/decisions.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions in force for a visitor: what a decisions response served and the page rendered,
|
|
3
|
+
* latest per slot. Every later event carries them as `context.decisions`, which is how a basket is
|
|
4
|
+
* credited to the slot shown before it (decision_rewards bounds that to the decision's session and
|
|
5
|
+
* attribution tail). They survive a navigation in sessionStorage, which is written only under a grant.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `context.decisions[].kind`, as spec core/context 1-0-0 closes it. Any other value is dropped
|
|
10
|
+
* rather than sent: one would quarantine the event carrying it, basket and all.
|
|
11
|
+
*/
|
|
12
|
+
export const DECISION_KINDS = [
|
|
13
|
+
'recommendation',
|
|
14
|
+
'search_rank',
|
|
15
|
+
'banner',
|
|
16
|
+
'offer',
|
|
17
|
+
'layout',
|
|
18
|
+
'copy',
|
|
19
|
+
'price',
|
|
20
|
+
'other',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const named = (value) => typeof value === 'string' && value !== '';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a decisions response served, as `context.decisions[]` entries. Only logged decisions: a
|
|
27
|
+
* held-out shopper, or a visitor without analytics consent, gets no `decision_id`, and has nothing
|
|
28
|
+
* a reward could join to.
|
|
29
|
+
*/
|
|
30
|
+
export function servedDecisions(body) {
|
|
31
|
+
return (Array.isArray(body?.decisions) ? body.decisions : [])
|
|
32
|
+
.filter((d) => named(d?.slot) && named(d.decision_id) && d.holdout !== true)
|
|
33
|
+
.map(({ decision_id, slot, arm, kind }) => ({
|
|
34
|
+
decision_id,
|
|
35
|
+
slot,
|
|
36
|
+
...(named(arm) && { arm }),
|
|
37
|
+
...(DECISION_KINDS.includes(kind) && { kind }),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} options
|
|
43
|
+
* @param {string} options.key The sessionStorage key, scoped to the property.
|
|
44
|
+
* @param {() => boolean} options.mayStore Whether a device write is allowed now.
|
|
45
|
+
*/
|
|
46
|
+
export function createDecisionMemory({ key, mayStore }) {
|
|
47
|
+
// Memory is authoritative within a document; storage only carries the decisions across a navigation.
|
|
48
|
+
let inForce;
|
|
49
|
+
const load = () => {
|
|
50
|
+
if (inForce) return inForce;
|
|
51
|
+
try {
|
|
52
|
+
const stored = JSON.parse(sessionStorage.getItem(key));
|
|
53
|
+
inForce = Array.isArray(stored)
|
|
54
|
+
? stored.filter((d) => named(d?.slot))
|
|
55
|
+
: [];
|
|
56
|
+
} catch {
|
|
57
|
+
inForce = [];
|
|
58
|
+
}
|
|
59
|
+
return inForce;
|
|
60
|
+
};
|
|
61
|
+
const persist = () => {
|
|
62
|
+
try {
|
|
63
|
+
if (inForce?.length && mayStore())
|
|
64
|
+
sessionStorage.setItem(key, JSON.stringify(inForce));
|
|
65
|
+
} catch {
|
|
66
|
+
/* private browsing: the decisions still hold within this document */
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
persist,
|
|
72
|
+
/** A copy, so a beforeSend that edits one event cannot edit the next. */
|
|
73
|
+
current: () => (load().length ? inForce.map((d) => ({ ...d })) : null),
|
|
74
|
+
/** Put these in force, replacing whatever each slot decided before. */
|
|
75
|
+
remember(served) {
|
|
76
|
+
if (!served.length) return;
|
|
77
|
+
const slots = new Set(served.map((d) => d.slot));
|
|
78
|
+
inForce = [...load().filter((d) => !slots.has(d.slot)), ...served];
|
|
79
|
+
persist();
|
|
80
|
+
},
|
|
81
|
+
forget() {
|
|
82
|
+
inForce = [];
|
|
83
|
+
try {
|
|
84
|
+
sessionStorage.removeItem(key);
|
|
85
|
+
} catch {
|
|
86
|
+
/* nothing was stored */
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two device writes beyond the SDK's own buffers.
|
|
3
|
+
*
|
|
4
|
+
* held store events the consent gate holds for an unanswered CMP, in sessionStorage per
|
|
5
|
+
* property, so a navigation before the answer (a sign-in redirect) does not lose
|
|
6
|
+
* them (`holdAcrossPages`, 3.5). They leave only on a grant, on whichever page it
|
|
7
|
+
* comes; a refusal deletes them.
|
|
8
|
+
* device cookie Hyper-Personalisation's device id, for a source whose settings say `hp_cookie`
|
|
9
|
+
* (3.6). Under strict consent it is written only once analytics is granted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getCookie } from './context.js';
|
|
13
|
+
import { uuid } from './shape.js';
|
|
14
|
+
import { storageKey, storageName } from './storage-scope.js';
|
|
15
|
+
|
|
16
|
+
/** How long held events may wait for an answer across pages. A tab left for longer starts over. */
|
|
17
|
+
export const HELD_MAX_AGE_MS = 30 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
const session = () => {
|
|
20
|
+
try {
|
|
21
|
+
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const local = () => {
|
|
28
|
+
try {
|
|
29
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} key A property-scoped key (storageKey(storageName('held'), propertyId)).
|
|
37
|
+
* @param {() => number} [now]
|
|
38
|
+
*/
|
|
39
|
+
export function createHeldStore(key, now = () => Date.now()) {
|
|
40
|
+
return {
|
|
41
|
+
/** @returns {{ items: object[], anonymousId: string|null }} */
|
|
42
|
+
read() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = session()?.getItem(key);
|
|
45
|
+
const saved = raw ? JSON.parse(raw) : null;
|
|
46
|
+
if (!saved || now() - saved.savedAt > HELD_MAX_AGE_MS) {
|
|
47
|
+
if (saved) session()?.removeItem(key);
|
|
48
|
+
return { items: [], anonymousId: null };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
items: Array.isArray(saved.items) ? saved.items : [],
|
|
52
|
+
anonymousId:
|
|
53
|
+
typeof saved.anonymousId === 'string' ? saved.anonymousId : null,
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return { items: [], anonymousId: null };
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
write(items, anonymousId = null) {
|
|
60
|
+
try {
|
|
61
|
+
if (items.length === 0) session()?.removeItem(key);
|
|
62
|
+
else
|
|
63
|
+
session()?.setItem(
|
|
64
|
+
key,
|
|
65
|
+
JSON.stringify({ savedAt: now(), items, anonymousId }),
|
|
66
|
+
);
|
|
67
|
+
} catch {
|
|
68
|
+
/* quota or private browsing: the events are still held for this page */
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 400 days, the longest a browser keeps a cookie (RFC 6265bis); refreshed on every page. */
|
|
75
|
+
export const DEVICE_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
|
|
76
|
+
|
|
77
|
+
export function writeCookie(name, value, maxAge, domain) {
|
|
78
|
+
if (typeof document === 'undefined') return false;
|
|
79
|
+
try {
|
|
80
|
+
const secure =
|
|
81
|
+
typeof location !== 'undefined' && location.protocol === 'https:'
|
|
82
|
+
? '; Secure'
|
|
83
|
+
: '';
|
|
84
|
+
const scope = domain ? `; Domain=${domain}` : '';
|
|
85
|
+
// No Max-Age: a session cookie, gone when the browser closes.
|
|
86
|
+
const age = maxAge === null ? '' : `; Max-Age=${maxAge}`;
|
|
87
|
+
document.cookie =
|
|
88
|
+
`${name}=${encodeURIComponent(value)}; Path=/${age}; SameSite=Lax` +
|
|
89
|
+
scope +
|
|
90
|
+
secure;
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Hyper-Personalisation's own device cookie (3.6, a source set to `hp_cookie`): a first-party
|
|
99
|
+
* cookie the SDK mints and keeps, one per browser rather than per property. Under strict consent
|
|
100
|
+
* it is written only once analytics is granted; until then the id lives in memory, and in the held
|
|
101
|
+
* store beside the events that carry it. Every page under a grant writes it again, so it lasts 400
|
|
102
|
+
* days from the last visit rather than the first.
|
|
103
|
+
*
|
|
104
|
+
* A device id 3.5 minted into localStorage (`mintAnonymousId`) is adopted as the cookie's value,
|
|
105
|
+
* and its localStorage key removed, the first time the cookie is written.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} options
|
|
108
|
+
* @param {string} options.name The cookie name, storageName('anonymousId').
|
|
109
|
+
* @param {string|null} [options.domain] As the consent cookie's (`consentCookieDomain`).
|
|
110
|
+
* @param {string} options.legacyKey 3.5's localStorage key for this property.
|
|
111
|
+
* @param {(name: string) => string|null} options.read Reads a cookie.
|
|
112
|
+
* @param {() => string} options.mint
|
|
113
|
+
* @param {() => boolean} options.mayStore Strict consent: analytics is granted.
|
|
114
|
+
* @param {string|null} [options.carried] An id carried in the held store from an earlier page.
|
|
115
|
+
*/
|
|
116
|
+
export function createDeviceCookie({
|
|
117
|
+
name,
|
|
118
|
+
domain = null,
|
|
119
|
+
legacyKey,
|
|
120
|
+
read,
|
|
121
|
+
mint,
|
|
122
|
+
mayStore,
|
|
123
|
+
carried = null,
|
|
124
|
+
}) {
|
|
125
|
+
let legacy = null;
|
|
126
|
+
try {
|
|
127
|
+
legacy = local()?.getItem(legacyKey) || null;
|
|
128
|
+
} catch {
|
|
129
|
+
legacy = null;
|
|
130
|
+
}
|
|
131
|
+
let id = read(name) ?? legacy ?? carried;
|
|
132
|
+
let written = false;
|
|
133
|
+
|
|
134
|
+
const persist = () => {
|
|
135
|
+
if (written || id === null || !mayStore()) return;
|
|
136
|
+
written = writeCookie(name, id, DEVICE_COOKIE_MAX_AGE, domain);
|
|
137
|
+
if (!written || legacy === null) return;
|
|
138
|
+
try {
|
|
139
|
+
local()?.removeItem(legacyKey);
|
|
140
|
+
legacy = null;
|
|
141
|
+
} catch {
|
|
142
|
+
/* left for the next page to remove */
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
get() {
|
|
148
|
+
id ??= mint();
|
|
149
|
+
persist();
|
|
150
|
+
return id;
|
|
151
|
+
},
|
|
152
|
+
/** The id so far, without minting one. */
|
|
153
|
+
peek: () => id,
|
|
154
|
+
/** Consent was granted: write what was minted before it. */
|
|
155
|
+
persist,
|
|
156
|
+
/** Consent was refused or withdrawn: the id must not outlive the permission it was kept under. */
|
|
157
|
+
forget() {
|
|
158
|
+
writeCookie(name, '', 0, domain);
|
|
159
|
+
written = false;
|
|
160
|
+
try {
|
|
161
|
+
local()?.removeItem(legacyKey);
|
|
162
|
+
} catch {
|
|
163
|
+
/* nothing was stored */
|
|
164
|
+
}
|
|
165
|
+
legacy = null;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where device ids come from, from the source's settings (`hp.anonymousId`, ADR-0017): one place,
|
|
172
|
+
* never a fallback between two.
|
|
173
|
+
*
|
|
174
|
+
* site_cookie the cookie the settings name, or (no name) `getAnonymousId`, the integration's
|
|
175
|
+
* own. Read only: a visitor without it has no device id.
|
|
176
|
+
* hp_cookie Hyper-Personalisation's cookie, minted and kept here. `getAnonymousId` is ignored.
|
|
177
|
+
* absent settings from before 3.6: `getAnonymousId`, as before.
|
|
178
|
+
*
|
|
179
|
+
* Replaces `cfg.getAnonymousId` to match, and returns the device cookie under `hp_cookie`.
|
|
180
|
+
*/
|
|
181
|
+
export function deviceIdFrom(cfg, { carried, mayStore }) {
|
|
182
|
+
const setting = cfg.anonymousIdSource;
|
|
183
|
+
if (setting?.source === 'site_cookie' && setting.cookie) {
|
|
184
|
+
const name = setting.cookie;
|
|
185
|
+
cfg.getAnonymousId = () => getCookie(name);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (setting?.source !== 'hp_cookie') return null;
|
|
189
|
+
const name = storageName('anonymousId');
|
|
190
|
+
const device = createDeviceCookie({
|
|
191
|
+
name,
|
|
192
|
+
domain: cfg.consentCookieDomain ?? null,
|
|
193
|
+
legacyKey: storageKey(name, cfg.propertyId),
|
|
194
|
+
read: getCookie,
|
|
195
|
+
mint: uuid,
|
|
196
|
+
mayStore,
|
|
197
|
+
carried,
|
|
198
|
+
});
|
|
199
|
+
cfg.getAnonymousId = () => device.get();
|
|
200
|
+
return device;
|
|
201
|
+
}
|