@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +825 -2
  3. package/dist/hp.min.js +3 -0
  4. package/dist/snippet.min.js +1 -0
  5. package/package.json +83 -3
  6. package/src/assignment.js +75 -0
  7. package/src/auto/attributes.js +16 -0
  8. package/src/auto/exposures.js +45 -0
  9. package/src/auto/lists.js +136 -0
  10. package/src/auto/promotions.js +104 -0
  11. package/src/auto/rails.js +217 -0
  12. package/src/auto/viewability.js +150 -0
  13. package/src/auto.js +13 -0
  14. package/src/bootstrap.js +476 -0
  15. package/src/carry.js +74 -0
  16. package/src/conformance.js +530 -0
  17. package/src/consent-gate.js +324 -0
  18. package/src/consent.js +604 -0
  19. package/src/context.js +439 -0
  20. package/src/datalayer.js +411 -0
  21. package/src/decisions.js +90 -0
  22. package/src/device-storage.js +201 -0
  23. package/src/experiments.js +220 -0
  24. package/src/ga4.js +124 -0
  25. package/src/hash.js +138 -0
  26. package/src/hp.js +135 -0
  27. package/src/index.js +818 -0
  28. package/src/preview.js +44 -0
  29. package/src/recommendation-context.js +101 -0
  30. package/src/segment.js +375 -0
  31. package/src/session/category.js +40 -0
  32. package/src/session/record.js +163 -0
  33. package/src/session/rules.js +195 -0
  34. package/src/session/signals.js +146 -0
  35. package/src/session-id.js +62 -0
  36. package/src/session.js +28 -0
  37. package/src/settings.js +211 -0
  38. package/src/shape.js +111 -0
  39. package/src/social-proof.js +139 -0
  40. package/src/spec-version.js +4 -0
  41. package/src/storage-scope.js +159 -0
  42. package/src/transport.js +436 -0
  43. package/src/version.js +2 -0
  44. package/types/auto.d.ts +51 -0
  45. package/types/bootstrap.d.ts +154 -0
  46. package/types/consent.d.ts +126 -0
  47. package/types/datalayer.d.ts +76 -0
  48. package/types/events.d.ts +680 -0
  49. package/types/hp.d.ts +64 -0
  50. package/types/index.d.ts +610 -0
  51. package/types/segment.d.ts +68 -0
package/src/index.js ADDED
@@ -0,0 +1,818 @@
1
+ /**
2
+ * @thg-agentic/hyper-personalisation-web — browser SDK for the THG Hyper-Personalisation event specification.
3
+ *
4
+ * It emits the wire format defined in @thg-agentic/hyper-personalisation-spec, as do the
5
+ * @segment/analytics-next plugins in ./segment.js. Most sites start it with
6
+ * `bootstrap({ writeKey, apiHost }, options)` from ./bootstrap.js, which fetches the tenant's
7
+ * settings and calls this. createAnalytics is the lower-level API:
8
+ *
9
+ * import { createAnalytics } from '@thg-agentic/hyper-personalisation-web';
10
+ *
11
+ * const analytics = createAnalytics({
12
+ * apiHost: 'https://api.example.com',
13
+ * writeKey: 'hp_pk_live_…',
14
+ * propertyId: 'acme-gb',
15
+ * consent: oneTrustConsent(),
16
+ * // Where device ids come from, as the source's settings say (`hp.anonymousId`). Without it,
17
+ * // getAnonymousId is the device id; with `hp_cookie` the SDK mints and keeps its own.
18
+ * anonymousIdSource: settings.anonymousId,
19
+ * getAnonymousId: () => getCookie('device_id'),
20
+ * getSessionId: () => getCookie('session_id'),
21
+ * // The rails on the page, stamped onto every event as context.recommendations.
22
+ * getRecommendations: () => railsOnThisPage,
23
+ * });
24
+ *
25
+ * analytics.page({ type: 'product' });
26
+ * analytics.productViewed({ products: [item] });
27
+ *
28
+ * Recommendation rails are instrumented from markup, against a viewability standard:
29
+ *
30
+ * import { trackRecommendationRails } from '@thg-agentic/hyper-personalisation-web/auto';
31
+ * import { assignRecommendationVariant } from '@thg-agentic/hyper-personalisation-web';
32
+ *
33
+ * // Before requesting the rail: a control arm must not call the recommender.
34
+ * const arm = assignRecommendationVariant({
35
+ * unit: getCookie('device_id'),
36
+ * experiment: 'reco_ranker_v2',
37
+ * variants: [{ name: 'blend', share: 0.5 }, { name: 'ranker', share: 0.5 }],
38
+ * holdoutShare: 0.05,
39
+ * });
40
+ *
41
+ * trackRecommendationRails(analytics);
42
+ */
43
+
44
+ import { assignmentSession, surfaceExposureToken } from './assignment.js';
45
+ import { hostConsent, servingHeaders } from './consent.js';
46
+ import { createConsentGate } from './consent-gate.js';
47
+ import { buildContext, getCookie, perRequest } from './context.js';
48
+ import { createDecisionMemory, servedDecisions } from './decisions.js';
49
+ import { createHeldStore, deviceIdFrom } from './device-storage.js';
50
+ import {
51
+ banditSplit,
52
+ holdoutAssignment,
53
+ holdoutShares,
54
+ } from './experiments.js';
55
+ import { emailSha256, sha256Hex } from './hash.js';
56
+ import { applyPreview } from './preview.js';
57
+ import {
58
+ forgetVisit,
59
+ keepVisit,
60
+ recommendationContextFrom,
61
+ } from './recommendation-context.js';
62
+ import { recordSessionEvent } from './session/record.js';
63
+ import {
64
+ clearSessionSignals,
65
+ getSessionSignals,
66
+ setSessionScope,
67
+ } from './session/signals.js';
68
+ import { createSessionId } from './session-id.js';
69
+ import { planAllows, resolveApiHost } from './settings.js';
70
+ import {
71
+ detectNavigationTrigger,
72
+ PAGE_TYPES,
73
+ pruneProperties,
74
+ realId,
75
+ uuid,
76
+ } from './shape.js';
77
+ import {
78
+ configureStorageNames,
79
+ migrateLegacyStorage,
80
+ storageKey,
81
+ storageName,
82
+ } from './storage-scope.js';
83
+ import { Transport } from './transport.js';
84
+ import { LIB, VERSION } from './version.js';
85
+
86
+ /** The surfaces a `Personalisation Exposed` names, as the public API's exposures do. */
87
+ const EXPOSURE_SURFACES = new Set([
88
+ 'audiences',
89
+ 'decisions',
90
+ 'recommendations',
91
+ ]);
92
+
93
+ export {
94
+ ASSIGNMENT_DECISION_NAMESPACE,
95
+ assignmentDecisionId,
96
+ } from './assignment.js';
97
+ export { defaultProductId, fromGa4Item, toProducts } from './ga4.js';
98
+ export { DEFAULT_API_HOST, resolveApiHost } from './settings.js';
99
+ export { PAGE_TYPES, pruneProperties } from './shape.js';
100
+ export { emailSha256, getCookie };
101
+
102
+ /** How a call made before the SDK started is replayed with the messageId and time it was made at. */
103
+ const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
104
+
105
+ /** SHA-256 of a string as lowercase hex. Synchronous since 3.9 (sha256Hex); kept async for callers. */
106
+ export async function sha256(value) {
107
+ return sha256Hex(String(value));
108
+ }
109
+
110
+ /**
111
+ * Cart Updated's properties from the platform's confirmed basket: the lines as given, and
112
+ * `item_count` summed from their quantities when the basket does not carry one. Prices are left
113
+ * alone: a line price includes tax where the market displays it so, and `value` does not, so the
114
+ * SDK never derives `value` from the lines.
115
+ */
116
+ export function cartSnapshot(basket) {
117
+ const props = { ...(basket ?? {}) };
118
+ const lines = Array.isArray(props.products) ? props.products : null;
119
+ if (props.item_count == null && lines) {
120
+ const quantities = lines.map((line) => line?.quantity);
121
+ if (quantities.every(Number.isInteger))
122
+ props.item_count = quantities.reduce((sum, q) => sum + q, 0);
123
+ }
124
+ return props;
125
+ }
126
+
127
+ /** As emailSha256, kept async for the callers that await it. */
128
+ export async function hashEmail(email) {
129
+ return emailSha256(email);
130
+ }
131
+
132
+ /**
133
+ * `phone_sha256` for identify: the E.164 number (`+` then 8 to 15 digits, spaces, dots, dashes and
134
+ * brackets removed), hashed. Null for a national number such as `07700 900123`: its country cannot
135
+ * be known here, and hashing it as typed would never match the same number sent in E.164.
136
+ */
137
+ export async function hashPhone(phone) {
138
+ if (typeof phone !== 'string') return null;
139
+ const normalised = phone.replace(/[\s.()-]/g, '');
140
+ if (!/^\+[1-9]\d{7,14}$/.test(normalised)) return null;
141
+ return sha256Hex(normalised);
142
+ }
143
+
144
+ /**
145
+ * The digest of the empty string: what a platform that hashes a blank email field sends for "no
146
+ * email". Every customer without an address would share it.
147
+ */
148
+ const EMPTY_SHA256 =
149
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
150
+
151
+ function hexDigest(text) {
152
+ if (/^[0-9a-fA-F]{64}$/.test(text)) return text.toLowerCase();
153
+ if (!/^[A-Za-z0-9+/_-]{43}=?$/.test(text)) return null;
154
+ try {
155
+ const binary = atob(
156
+ text.replace(/-/g, '+').replace(/_/g, '/').padEnd(44, '='),
157
+ );
158
+ if (binary.length !== 32) return null;
159
+ return Array.from(binary, (c) =>
160
+ c.charCodeAt(0).toString(16).padStart(2, '0'),
161
+ ).join('');
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * A SHA-256 digest as the lowercase hex `email_sha256` wants, from hex or base64 (standard or
169
+ * URL-safe). Re-encodes, never re-hashes. Null for anything that is not a 32-byte digest, and for
170
+ * the digest of the empty string.
171
+ */
172
+ export function normaliseSha256(value) {
173
+ if (typeof value !== 'string') return null;
174
+ const hex = hexDigest(value.trim());
175
+ return hex === EMPTY_SHA256 ? null : hex;
176
+ }
177
+
178
+ // On the main entry so integrators find them: without an assigned variant a rail is unmeasurable.
179
+ export {
180
+ assignRecommendationVariant,
181
+ banditSplit,
182
+ candidateSetFingerprint,
183
+ hash32,
184
+ hashBucket,
185
+ holdoutAssignment,
186
+ } from './experiments.js';
187
+ /** Social proof: the one part of this package that reads rather than writes. */
188
+ export { createSocialProof } from './social-proof.js';
189
+
190
+ export { VERSION };
191
+
192
+ let warnedMintAnonymousId = false;
193
+
194
+ /** 3.5's `mintAnonymousId` does nothing since 3.6: where device ids come from is the source's. */
195
+ function warnMintAnonymousId(debug) {
196
+ if (!debug || warnedMintAnonymousId) return;
197
+ warnedMintAnonymousId = true;
198
+ console.warn(
199
+ '[hp] mintAnonymousId is ignored since 3.6: where device ids come from is set per source in the console (the site’s own cookie, or Hyper-Personalisation’s).',
200
+ );
201
+ }
202
+
203
+ /** Per-call context overrides, given directly or analytics-next style as `{ context }`. */
204
+ function contextOverrides(options) {
205
+ if (
206
+ options &&
207
+ typeof options.context === 'object' &&
208
+ options.context !== null
209
+ )
210
+ return options.context;
211
+ return options;
212
+ }
213
+
214
+ export function createAnalytics(options) {
215
+ const apiHost = resolveApiHost(options?.apiHost);
216
+ if (!apiHost)
217
+ throw new Error(
218
+ 'createAnalytics: apiHost, when given, must be a host or an http(s) origin',
219
+ );
220
+ if (!options?.propertyId)
221
+ throw new Error('createAnalytics: propertyId is required');
222
+
223
+ const cfg = {
224
+ libraryName: LIB,
225
+ libraryVersion: VERSION,
226
+ writeKey: 'web',
227
+ queryParamBlocklist: [],
228
+ debug: false,
229
+ validate: null, // inject @thg-agentic/hyper-personalisation-spec's validateEvent in dev
230
+ consentTimeoutMs: 5000,
231
+ ...options,
232
+ apiHost,
233
+ };
234
+
235
+ configureStorageNames(cfg.storageNames);
236
+ migrateLegacyStorage();
237
+
238
+ // A consent adapter makes the SDK strict: nothing is sent or stored until analytics is granted.
239
+ // Without one the host has decided the lawful basis itself, and every event says so (spec 1.5).
240
+ const strict = Boolean(cfg.consent);
241
+ let gate = null;
242
+ cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
243
+ const granted = () => !strict || gate?.status === 'granted';
244
+
245
+ // Opt-in (3.5): events held for an unanswered CMP survive a navigation, in sessionStorage.
246
+ const heldStore =
247
+ strict && cfg.holdAcrossPages
248
+ ? createHeldStore(storageKey(storageName('held'), cfg.propertyId))
249
+ : null;
250
+ const carried = heldStore?.read() ?? { items: [], anonymousId: null };
251
+
252
+ if (cfg.mintAnonymousId !== undefined) warnMintAnonymousId(cfg.debug);
253
+ const device = deviceIdFrom(cfg, {
254
+ carried: carried.anonymousId,
255
+ mayStore: granted,
256
+ });
257
+
258
+ // No session from the host: the SDK keeps one (3.15). Never a second beside the host's.
259
+ const session =
260
+ cfg.getSessionId == null
261
+ ? createSessionId({
262
+ name: storageName('session'),
263
+ domain: cfg.consentCookieDomain ?? null,
264
+ mayStore: granted,
265
+ })
266
+ : null;
267
+ if (session) cfg.getSessionId = () => session.get();
268
+
269
+ const previewing = applyPreview(
270
+ storageName('preview'),
271
+ cfg.consentCookieDomain ?? null,
272
+ );
273
+
274
+ const transport =
275
+ cfg.transport ??
276
+ new Transport({
277
+ apiHost: cfg.apiHost,
278
+ writeKey: cfg.writeKey,
279
+ // Per tenant: a persisted batch is replayed through whichever instance starts next, with its
280
+ // writeKey, and one origin can serve several tenants.
281
+ storageKey: storageKey(storageName('retry'), cfg.propertyId),
282
+ // Under strict consent, persisting is a device write and replaying is a send: both wait for a
283
+ // grant (see onStatus below, which replays on a grant and clears on a refusal).
284
+ mayPersist: strict ? () => gate?.status === 'granted' : undefined,
285
+ deferReplay: strict,
286
+ onError: (m, e) => cfg.debug && console.warn('[hp]', m, e ?? ''),
287
+ });
288
+
289
+ // The previous page view's messageId, carried across navigations in sessionStorage, per tenant.
290
+ const PREV_PAGE_KEY = storageKey(storageName('previousPage'), cfg.propertyId);
291
+
292
+ // Memory is authoritative within a document; storage only carries the link across a navigation.
293
+ let lastPageMessageId = null;
294
+
295
+ const readLastPageMessageId = () => {
296
+ if (lastPageMessageId) return lastPageMessageId;
297
+ try {
298
+ return sessionStorage.getItem(PREV_PAGE_KEY) || null;
299
+ } catch {
300
+ return null;
301
+ }
302
+ };
303
+
304
+ const writeLastPageMessageId = (id) => {
305
+ lastPageMessageId = id || null;
306
+ if (strict && gate.status !== 'granted') return;
307
+ try {
308
+ sessionStorage.setItem(PREV_PAGE_KEY, id);
309
+ } catch {
310
+ /* private browsing: the chain still holds within this document */
311
+ }
312
+ };
313
+ // What decisioned slots served, stamped on every later event unless the host supplies its own.
314
+ const decisions = createDecisionMemory({
315
+ key: storageKey(storageName('decisions'), cfg.propertyId),
316
+ mayStore: granted,
317
+ });
318
+ cfg.getDecisions ??= decisions.current;
319
+
320
+ let userId = realId(perRequest(cfg.getUserId, null));
321
+
322
+ /** The call being replayed: its first event keeps the messageId and time it was made with. */
323
+ let replaying = null;
324
+
325
+ function emit(partial, ctxOverrides, force = false) {
326
+ const made = replaying;
327
+ replaying = null;
328
+ let event = {
329
+ messageId: made?.messageId ?? uuid(),
330
+ timestamp: made?.timestamp ?? new Date().toISOString(),
331
+ anonymousId: realId(perRequest(cfg.getAnonymousId, null)),
332
+ userId: userId ?? null,
333
+ ...partial,
334
+ context: buildContext(cfg, contextOverrides(ctxOverrides)),
335
+ };
336
+
337
+ if (event.properties) event.properties = pruneProperties(event.properties);
338
+
339
+ // A redaction or sampling hook may change what an event says or drop it, never what it is.
340
+ // One that fails drops the event rather than sending what it was meant to redact.
341
+ if (cfg.beforeSend) {
342
+ let shaped;
343
+ try {
344
+ shaped = cfg.beforeSend(event);
345
+ } catch (err) {
346
+ console.warn('[hp] beforeSend failed; the event was not sent', err);
347
+ return null;
348
+ }
349
+ if (!shaped) return null;
350
+ const { type, event: name, messageId, timestamp } = event;
351
+ event = {
352
+ ...shaped,
353
+ type,
354
+ messageId,
355
+ timestamp,
356
+ ...(name ? { event: name } : {}),
357
+ };
358
+ }
359
+
360
+ // Injected rather than imported, so the validator and ajv never reach a production bundle.
361
+ if (cfg.validate) {
362
+ const res = cfg.validate(event, { producer: 'web' });
363
+ if (!res.valid) {
364
+ const msg = `[hp] invalid ${event.event ?? event.type}:\n ${res.errors.join('\n ')}`;
365
+ if (cfg.throwOnInvalid) throw new Error(msg);
366
+ console.error(msg);
367
+ }
368
+ }
369
+
370
+ if (cfg.debug) console.debug('[hp]', event.event ?? event.type, event);
371
+
372
+ deliver(event, force);
373
+ return event;
374
+ }
375
+
376
+ function observe(event) {
377
+ if (event.type === 'track') recordVisit(event);
378
+ // Lets a caller fold the event the warehouse will receive into session state. An observer
379
+ // that throws must not cost the event.
380
+ if (cfg.onEvent) {
381
+ try {
382
+ cfg.onEvent(event);
383
+ } catch (err) {
384
+ console.warn('[hp] onEvent observer failed', err);
385
+ }
386
+ }
387
+ }
388
+
389
+ /** The visit is kept on the device only for a visitor who allowed personalisation. */
390
+ const personalisationGranted = () =>
391
+ strict && gate?.state?.personalisation === true;
392
+ const scopeVisit = () =>
393
+ setSessionScope(perRequest(cfg.getSessionId, null), cfg.propertyId);
394
+
395
+ let visitKept = false;
396
+
397
+ /** What `recommendationContext()` reads: the products and searches of the events sent. */
398
+ function recordVisit(event) {
399
+ try {
400
+ if (personalisationGranted()) {
401
+ scopeVisit();
402
+ keepVisit(
403
+ recommendationContextFrom(
404
+ recordSessionEvent(event.event, event.properties),
405
+ ),
406
+ );
407
+ visitKept = true;
408
+ } else {
409
+ // Not allowed, or withdrawn: what was kept under the permission goes with it.
410
+ forgetVisit();
411
+ if (visitKept) clearSessionSignals();
412
+ visitKept = false;
413
+ }
414
+ } catch (err) {
415
+ if (cfg.debug) console.warn('[hp] the visit was not recorded', err);
416
+ }
417
+ }
418
+
419
+ function deliver(event, force) {
420
+ // The observer only sees what will be sent, never an event the visitor has not allowed.
421
+ if (!strict || force || gate.admits()) {
422
+ observe(event);
423
+ transport.enqueue(event);
424
+ } else {
425
+ gate.hold(event);
426
+ }
427
+ }
428
+
429
+ const track = (name, properties, ctx) =>
430
+ planAllows(cfg.plan, name)
431
+ ? emit({ type: 'track', event: name, properties: properties ?? {} }, ctx)
432
+ : null;
433
+
434
+ // One exposure event per serving response, however often the page reuses it.
435
+ const exposedTokens = new Set();
436
+
437
+ /** What the response personalised, by surface. Nothing in the holdout: nothing was applied. */
438
+ const exposed = (surface, holdout, body) => {
439
+ if (surface === 'audiences')
440
+ return { audiences: holdout ? null : (body.audiences ?? []) };
441
+ if (surface === 'decisions')
442
+ return {
443
+ decisions: (body.decisions ?? []).map((d) => ({
444
+ slot: d.slot,
445
+ decision_id: d.decision_id ?? null,
446
+ arm: d.arm ?? null,
447
+ ...(d.variant && { variant: d.variant }),
448
+ })),
449
+ };
450
+ return {
451
+ recommendation_request_id: body.recommendation_request_id ?? null,
452
+ };
453
+ };
454
+
455
+ if (strict) {
456
+ gate = createConsentGate({
457
+ adapter: cfg.consent,
458
+ timeoutMs: cfg.consentTimeoutMs,
459
+ cookieDomain: cfg.consentCookieDomain,
460
+ debug: cfg.debug,
461
+ store: heldStore && {
462
+ read: () => carried.items,
463
+ write: (items) => heldStore.write(items, device?.peek() ?? null),
464
+ },
465
+ release(event) {
466
+ // Only the consent block is rewritten; everything else was true when it was collected.
467
+ if (event.context) event.context.consent = gate.stamp();
468
+ observe(event);
469
+ transport.enqueue(event);
470
+ },
471
+ onStatus(status) {
472
+ if (status === 'granted') {
473
+ device?.persist();
474
+ session?.persist();
475
+ if (lastPageMessageId) writeLastPageMessageId(lastPageMessageId);
476
+ decisions.persist();
477
+ transport.replayPersisted?.();
478
+ }
479
+ if (status === 'denied') {
480
+ // Nothing kept on the device may outlive the permission it was kept under.
481
+ transport.clearPersisted?.();
482
+ device?.forget();
483
+ session?.forget();
484
+ try {
485
+ sessionStorage.removeItem(PREV_PAGE_KEY);
486
+ } catch {
487
+ /* nothing was stored */
488
+ }
489
+ decisions.forget();
490
+ }
491
+ try {
492
+ cfg.onConsentStatus?.(status);
493
+ } catch (err) {
494
+ console.warn('[hp] onConsentStatus observer failed', err);
495
+ }
496
+ },
497
+ announce({ action, state, withdrawal }) {
498
+ emit(
499
+ {
500
+ type: 'track',
501
+ event: 'Consent Updated',
502
+ properties: { action, state },
503
+ },
504
+ undefined,
505
+ withdrawal,
506
+ );
507
+ if (withdrawal) transport.flush();
508
+ },
509
+ });
510
+ gate.start();
511
+ }
512
+
513
+ /**
514
+ * This shopper's group, as the edge and the public API decide it: `holdout` and `excluded` (outside
515
+ * the property's ramp) both get the default everywhere, so personalise in the page only for
516
+ * `personalised`.
517
+ */
518
+ /** The property's holdout split from the settings, as a serving response would state it. */
519
+ const configuredShares = () =>
520
+ cfg.holdout ? holdoutShares(cfg.holdout) : null;
521
+
522
+ const personalisationGroup = () =>
523
+ holdoutAssignment(cfg.holdout, realId(perRequest(cfg.getAnonymousId, null)))
524
+ .group;
525
+
526
+ /**
527
+ * The response with each readout slot's side of its bandit-vs-default 50/50 (`variant`): the
528
+ * response's own, else this visitor's from the settings, the hash serving used. A page cached per
529
+ * arm has no response, so its side is computed here. The default side is no decision: its id is
530
+ * dropped, so it is never in force and never names a decision. A held-out visitor is in no split.
531
+ */
532
+ function withSplit(body, holdout) {
533
+ if (!Array.isArray(body?.decisions)) return body;
534
+ const readout = cfg.holdout?.readoutSlots ?? [];
535
+ const visitor = realId(perRequest(cfg.getAnonymousId, null));
536
+ return {
537
+ ...body,
538
+ decisions: body.decisions.map((d) => {
539
+ const variant =
540
+ d?.variant === 'bandit' || d?.variant === 'default'
541
+ ? d.variant
542
+ : !holdout && d?.holdout !== true && readout.includes(d?.slot)
543
+ ? banditSplit(d.slot, visitor)
544
+ : null;
545
+ if (!variant) return d;
546
+ return variant === 'default'
547
+ ? { ...d, variant, decision_id: null }
548
+ : { ...d, variant };
549
+ }),
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Log the exposure a serving response carries (membership, decisions or recommendations):
555
+ * pass the parsed body after rendering it. Null when the response personalised nothing, or its
556
+ * token was already recorded. A decisions response's decisions are then in force: every later
557
+ * event this session carries them as context.decisions, unless the host passes getDecisions.
558
+ *
559
+ * A page rendered server-side from `/v1/assignments` and cached has no serving response, so it
560
+ * passes the exposure itself (`token` the assignment's derived id, `holdout` from the page's
561
+ * hash) and omits the shares: they are then the property's, from these settings, so the
562
+ * sample-ratio check can read the split as it does for any response.
563
+ */
564
+ function recordExposure(response, c) {
565
+ // A trader previewing an arm is not a shopper: nothing they see is exposure or in force.
566
+ if (previewing) return null;
567
+ const exposure = response?.exposure;
568
+ const holdout = exposure?.holdout === true;
569
+ const body = withSplit(response, holdout);
570
+ // In force from the render, so a basket later in the session is credited to the slot.
571
+ decisions.remember(servedDecisions(body));
572
+ if (!exposure?.token || exposedTokens.has(exposure.token)) return null;
573
+ exposedTokens.add(exposure.token);
574
+ return track(
575
+ 'Personalisation Exposed',
576
+ {
577
+ exposure_token: exposure.token,
578
+ surface: exposure.surface,
579
+ holdout,
580
+ holdout_share:
581
+ exposure.holdout_share ?? configuredShares()?.holdout_share ?? null,
582
+ personalised_share:
583
+ exposure.personalised_share ??
584
+ configuredShares()?.personalised_share ??
585
+ null,
586
+ ...exposed(exposure.surface, holdout, body),
587
+ },
588
+ c,
589
+ );
590
+ }
591
+
592
+ return {
593
+ /** Escape hatch for events not yet in the core spec. Prefer a named method. */
594
+ track,
595
+
596
+ page(props = {}, ctx) {
597
+ // page.type is inherited by every later event on the page, so an unknown value would get
598
+ // them all rejected. Keep the last good type instead: stale beats invalid.
599
+ let pageType = cfg.pageType ?? null;
600
+ if (props.type) {
601
+ if (PAGE_TYPES.has(props.type)) {
602
+ pageType = props.type;
603
+ cfg.pageType = props.type;
604
+ } else if (cfg.debug) {
605
+ console.warn(
606
+ `[hp] ignoring unknown page.type "${props.type}". Allowed: ${[...PAGE_TYPES].join(', ')}`,
607
+ );
608
+ }
609
+ }
610
+
611
+ const ev = emit(
612
+ {
613
+ type: 'page',
614
+ properties: {
615
+ name: props.name ?? null,
616
+ navigation_trigger:
617
+ props.navigation_trigger ?? detectNavigationTrigger(),
618
+ previous_message_id: readLastPageMessageId(),
619
+ },
620
+ },
621
+ // page.type is a context field, inherited by every later event on this page.
622
+ { page: { type: pageType }, ...(contextOverrides(ctx) ?? {}) },
623
+ );
624
+ if (ev) writeLastPageMessageId(ev.messageId);
625
+ return ev;
626
+ },
627
+
628
+ identify(traits, ctx) {
629
+ const { userId: id, ...rest } =
630
+ traits && typeof traits === 'object' ? traits : {};
631
+ if (id !== undefined) userId = realId(id);
632
+ // A digest in hex or base64 is sent as hex; anything that is not one is not sent.
633
+ if (typeof rest.email_sha256 === 'string') {
634
+ const email = normaliseSha256(rest.email_sha256);
635
+ if (email) rest.email_sha256 = email;
636
+ else delete rest.email_sha256;
637
+ }
638
+ return emit({ type: 'identify', traits: rest }, ctx);
639
+ },
640
+
641
+ /** Clear identity on sign-out so the next events are not attributed to the old user. */
642
+ reset() {
643
+ userId = null;
644
+ writeLastPageMessageId('');
645
+ },
646
+
647
+ productViewed: (p, c) => track('Product Viewed', p, c),
648
+ productListViewed: (p, c) => track('Product List Viewed', p, c),
649
+
650
+ /**
651
+ * A recommendation rail was on screen.
652
+ *
653
+ * Prefer `trackRecommendationRails` from ./auto, which fires on the MRC viewability standard
654
+ * and fills in `viewable`. Call this directly only if the host measures viewability to the same
655
+ * standard itself; omit `viewable` rather than guess it.
656
+ */
657
+ recommendationsViewed: (p, c) => track('Recommendations Viewed', p, c),
658
+
659
+ /**
660
+ * A serving response personalised the page, or would have outside the holdout. Prefer
661
+ * `recordExposure`, which reads the response's `exposure` block.
662
+ */
663
+ personalisationExposed: (p, c) => track('Personalisation Exposed', p, c),
664
+
665
+ recordExposure,
666
+
667
+ /**
668
+ * A surface rendered server-side from a serving answer the page never sees (a product rail
669
+ * the site's server fetched from HP) came into view: record the property holdout's exposure for it, for
670
+ * both groups on the same trigger, so the readout counts like with like. The group is this
671
+ * visitor's as serving decides it, from the same holdout settings, salt and visitor id
672
+ * (spec/assignment/holdout.json), so it is the one serving answered with. Nothing is recorded
673
+ * for a visitor outside the experiment: without the property's holdout in the settings, outside
674
+ * the ramp, with no visitor or session id, or where serving personalises the surface only with
675
+ * consent this visitor has not given (rails and audiences need personalisation; decisions run
676
+ * on analytics, which every event already needs). One exposure per session per surface.
677
+ */
678
+ surfaceViewed({ surface } = {}, c) {
679
+ if (!EXPOSURE_SURFACES.has(surface) || !cfg.holdout) return null;
680
+ if (surface !== 'decisions' && !personalisationGranted()) return null;
681
+ const visitor = realId(perRequest(cfg.getAnonymousId, null));
682
+ const group = holdoutAssignment(cfg.holdout, visitor).group;
683
+ if (group === 'excluded') return null;
684
+ const token = surfaceExposureToken({
685
+ sessionId: assignmentSession(
686
+ perRequest(cfg.getSessionId, null),
687
+ visitor,
688
+ ),
689
+ surface,
690
+ });
691
+ if (!token) return null;
692
+ return recordExposure(
693
+ { exposure: { token, surface, holdout: group === 'holdout' } },
694
+ c,
695
+ );
696
+ },
697
+
698
+ /**
699
+ * Whether this shopper must see the default: held out, or outside the property's ramp
700
+ * (excluded), as the edge and the public API decide it. Skip anything personalised in the page
701
+ * itself (session-evaluated audiences, client re-ranking) when true. `personalisationGroup()`
702
+ * gives the exact group. An exposure's `holdout` flag comes from the serving response, never
703
+ * from this, except in `surfaceViewed`, for a surface whose response the page never sees.
704
+ */
705
+ inHoldout: () => personalisationGroup() !== 'personalised',
706
+ personalisationGroup,
707
+ productClicked: (p, c) => track('Product Clicked', p, c),
708
+ productListFiltered: (p, c) => track('Product List Filtered', p, c),
709
+ productsSearched: (p, c) => track('Products Searched', p, c),
710
+ productAdded: (p, c) => track('Product Added', p, c),
711
+ productRemoved: (p, c) => track('Product Removed', p, c),
712
+ /**
713
+ * The basket as the platform confirmed it after a change: pass the basket your cart API
714
+ * returned, every line with its total quantity. Send it alongside productAdded and
715
+ * productRemoved, never instead of them. `item_count` is summed from the lines when omitted.
716
+ */
717
+ cartUpdated: (basket, c) => track('Cart Updated', cartSnapshot(basket), c),
718
+ /**
719
+ * An identity edge an identify cannot imply: a guest checkout, a newsletter sign-up, an email
720
+ * click. Send the event-named trigger (`signed_in`, not `user_login`).
721
+ */
722
+ identityLinked: (p, c) => track('Identity Linked', p, c),
723
+ /** A visitor saw, clicked or dismissed a decisioned slot: the bandit's reward signal. */
724
+ slotEngaged: (p, c) => (previewing ? null : track('Slot Engaged', p, c)),
725
+ /** A size, colour or flavour the shopper picked: product_id the master, sku the new variant. */
726
+ productVariantSelected: (p, c) => track('Product Variant Selected', p, c),
727
+ cartViewed: (p, c) => track('Cart Viewed', p, c),
728
+ productAddedToWishlist: (p, c) => track('Product Added to Wishlist', p, c),
729
+ productRemovedFromWishlist: (p, c) =>
730
+ track('Product Removed from Wishlist', p, c),
731
+ /** A wishlist conversion. Send productAdded for the cart add as well: they are two facts. */
732
+ wishlistProductAddedToCart: (p, c) =>
733
+ track('Wishlist Product Added to Cart', p, c),
734
+ productShared: (p, c) => track('Product Shared', p, c),
735
+ cartShared: (p, c) => track('Cart Shared', p, c),
736
+
737
+ // Segment fires the coupon events from wherever the code is handled. Report each coupon fact
738
+ // from ONE producer per property, browser or server, or it is counted twice.
739
+ couponEntered: (p, c) => track('Coupon Entered', p, c),
740
+ couponApplied: (p, c) => track('Coupon Applied', p, c),
741
+ couponDenied: (p, c) => track('Coupon Denied', p, c),
742
+ couponRemoved: (p, c) => track('Coupon Removed', p, c),
743
+
744
+ // Order outcomes are backend-only: the server SDK sends them, and the ingest refuses them from
745
+ // a web key.
746
+ checkoutStarted: (p, c) => track('Checkout Started', p, c),
747
+ promotionViewed: (p, c) => track('Promotion Viewed', p, c),
748
+ promotionClicked: (p, c) => track('Promotion Clicked', p, c),
749
+ checkoutStepViewed: (p, c) => track('Checkout Step Viewed', p, c),
750
+ checkoutStepCompleted: (p, c) => track('Checkout Step Completed', p, c),
751
+ /** Send INSTEAD of checkoutStepCompleted for the payment step, never as well as it. */
752
+ paymentInfoEntered: (p, c) => track('Payment Info Entered', p, c),
753
+ /** Where checkout failed and why. The kind of payment instrument only, never card data. */
754
+ checkoutFailed: (p, c) => track('Checkout Failed', p, c),
755
+
756
+ signedIn: (p, c) => track('Signed In', p, c),
757
+ signedUp: (p, c) => track('Signed Up', p, c),
758
+ /** Sent under the signed-in identity, which is then cleared for whoever uses the device next. */
759
+ signedOut(c) {
760
+ const event = track('Signed Out', {}, c);
761
+ userId = null;
762
+ return event;
763
+ },
764
+ newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
765
+ /** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
766
+ newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
767
+ /** An alert request for one product: back in stock, a price drop or a launch. */
768
+ backInStockSubscribed: (p, c) => track('Back In Stock Subscribed', p, c),
769
+ productReviewed: (p, c) => track('Product Reviewed', p, c),
770
+ consentUpdated: (p, c) => track('Consent Updated', p, c),
771
+
772
+ flush: (o) => transport.flush(o),
773
+ /**
774
+ * This visit as the recommendations API's session context: `viewed`, `basket`, `q` and
775
+ * `impressed`, newest first, in its wire form. `{}` unless the visitor allowed personalisation.
776
+ * Synchronous and never throws: `new URLSearchParams(context)` is what to append. Since 3.11.
777
+ */
778
+ recommendationContext() {
779
+ if (!personalisationGranted()) return {};
780
+ try {
781
+ scopeVisit();
782
+ return recommendationContextFrom(getSessionSignals());
783
+ } catch {
784
+ return {};
785
+ }
786
+ },
787
+ /** The consent state in force (strict mode), or null while the CMP has not answered. */
788
+ get consent() {
789
+ return gate ? gate.state : null;
790
+ },
791
+ /**
792
+ * Headers every request to the serving API must carry: `hp-consent`, so serving personalises
793
+ * only what the visitor allowed. Spread them into the recommendations or decisions fetch.
794
+ */
795
+ servingHeaders: () => servingHeaders(gate?.state ?? null),
796
+ get lastPageMessageId() {
797
+ return readLastPageMessageId();
798
+ },
799
+ /** The device id events carry: the site's cookie's, or under `hp_cookie` the SDK's own. */
800
+ get anonymousId() {
801
+ return realId(perRequest(cfg.getAnonymousId, null));
802
+ },
803
+ /** `hp` and bootstrap replay a call made before the start through this. Not public API. */
804
+ [REPLAY](method, args, made) {
805
+ if (typeof this[method] !== 'function') return;
806
+ replaying =
807
+ typeof made?.messageId === 'string' &&
808
+ typeof made?.timestamp === 'string'
809
+ ? { messageId: made.messageId, timestamp: made.timestamp }
810
+ : null;
811
+ try {
812
+ this[method](...(Array.isArray(args) ? args : []));
813
+ } finally {
814
+ replaying = null;
815
+ }
816
+ },
817
+ };
818
+ }