@thg-agentic/hyper-personalisation-web 3.24.1 → 3.26.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.
@@ -98,7 +98,8 @@ function previousFromCookie(name) {
98
98
  * @param {boolean} [options.debug]
99
99
  * @param {{ read: () => any[], write: (items: any[]) => void }} [options.store]
100
100
  * Carry held items across pages (`holdAcrossPages`). Read on start, so a grant on the next page
101
- * releases what the previous one held; cleared on a grant or a refusal, never at unload.
101
+ * releases what the previous one held; written when the page is hidden, cleared on a grant or a
102
+ * refusal.
102
103
  */
103
104
  export function createConsentGate(options) {
104
105
  const {
@@ -240,9 +241,12 @@ export function createConsentGate(options) {
240
241
  */
241
242
  function onHidden(event) {
242
243
  if (!awaitingAnswer()) return;
243
- // With a store, the stored copy is what the next page answers for: the end of this one
244
- // discards nothing.
245
- if (store) return;
244
+ // With a store, the stored copy is what the next page answers for: written here, once, rather
245
+ // than on every held event, and the end of this page discards nothing.
246
+ if (store) {
247
+ store.write(held);
248
+ return;
249
+ }
246
250
  if (event?.type !== 'visibilitychange' && !event?.persisted) discardHeld();
247
251
  }
248
252
 
@@ -297,7 +301,6 @@ export function createConsentGate(options) {
297
301
  return false;
298
302
  }
299
303
  held.push(item);
300
- store?.write(held);
301
304
  return true;
302
305
  },
303
306
  /** Items unload would lose; the caller's own unload path flushes after this. */
package/src/context.js CHANGED
@@ -344,7 +344,8 @@ export const CONTEXT_KEYS = [
344
344
 
345
345
  export function sourceContext(cfg) {
346
346
  return {
347
- property_id: String(cfg.propertyId),
347
+ // None known (BUILT_IN settings): the ingest stamps the key's.
348
+ ...(cfg.propertyId == null ? {} : { property_id: String(cfg.propertyId) }),
348
349
  channel: cfg.channel ?? null,
349
350
  market: cfg.market ?? null,
350
351
  environment: cfg.environment ?? null,
@@ -2,9 +2,9 @@
2
2
  * Two device writes beyond the SDK's own buffers.
3
3
  *
4
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.
5
+ * write key, so a navigation before the answer (a sign-in redirect) does not lose
6
+ * them (`holdAcrossPages`, 3.5). They leave only on a
7
+ * grant, on whichever page it comes; a refusal deletes them.
8
8
  * device cookie Hyper-Personalisation's device id, for a source whose settings say `hp_cookie`
9
9
  * (3.6). Under strict consent it is written only once analytics is granted.
10
10
  */
@@ -33,7 +33,7 @@ const local = () => {
33
33
  };
34
34
 
35
35
  /**
36
- * @param {string} key A property-scoped key (storageKey(storageName('held'), propertyId)).
36
+ * @param {string} key A write-key-scoped key (storageKey(storageName('held'), writeKey)).
37
37
  * @param {() => number} [now]
38
38
  */
39
39
  export function createHeldStore(key, now = () => Date.now()) {
package/src/index.js CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  getCookie,
51
51
  localeTag,
52
52
  perRequest,
53
+ readCampaign,
53
54
  scrubHref,
54
55
  } from './context.js';
55
56
  import { createDecisionMemory, servedDecisions } from './decisions.js';
@@ -82,6 +83,7 @@ import {
82
83
  PAGE_TYPES,
83
84
  pruneProperties,
84
85
  realId,
86
+ tokenOf,
85
87
  uuid,
86
88
  } from './shape.js';
87
89
  import {
@@ -96,6 +98,12 @@ import {
96
98
  byteLength as utf8Bytes,
97
99
  } from './transport.js';
98
100
  import { LIB, VERSION } from './version.js';
101
+ import {
102
+ clearVisitCampaign,
103
+ keepVisitCampaign,
104
+ readVisitCampaign,
105
+ visitCampaign,
106
+ } from './visit-campaign.js';
99
107
 
100
108
  /** The surfaces a `Personalisation Exposed` names, as the public API's exposures do. */
101
109
  const EXPOSURE_SURFACES = new Set([
@@ -115,6 +123,10 @@ export { emailSha256, getCookie };
115
123
 
116
124
  /** How a call made before the SDK started is replayed with the messageId and time it was made at. */
117
125
  const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
126
+ /** How bootstrap tells the client its API could not be reached. Not public API. */
127
+ const UNREACHABLE = Symbol.for(
128
+ '@thg-agentic/hyper-personalisation-web.unreachable',
129
+ );
118
130
  /** How ./onsite reads what it needs from a client it was handed. Not public API. */
119
131
  const ONSITE_ACCESS = Symbol.for(
120
132
  '@thg-agentic/hyper-personalisation-web.onsite-access',
@@ -309,14 +321,65 @@ function contextOverrides(options) {
309
321
  return options;
310
322
  }
311
323
 
324
+ /**
325
+ * Before 3.25 the retry buffer and the held store were per property. What a 3.24 page kept there
326
+ * moves to this write key's, oldest first, and the old key goes: the batches keep their messageIds
327
+ * and are replayed as any other, under the same consent rules.
328
+ */
329
+ function adoptPropertyScopedBuffers(propertyId, writeKey) {
330
+ if (propertyId == null || String(propertyId) === writeKey) return;
331
+ const moves = [
332
+ [
333
+ 'localStorage',
334
+ 'retry',
335
+ (old, current) => [
336
+ ...(Array.isArray(old) ? old : []),
337
+ ...(Array.isArray(current) ? current : []),
338
+ ],
339
+ ],
340
+ [
341
+ 'sessionStorage',
342
+ 'held',
343
+ (old, current) =>
344
+ current
345
+ ? {
346
+ ...current,
347
+ items: [...(old.items ?? []), ...(current.items ?? [])],
348
+ }
349
+ : old,
350
+ ],
351
+ ];
352
+ for (const [store, name, merge] of moves) {
353
+ try {
354
+ const storage = globalThis[store];
355
+ const from = storageKey(storageName(name), propertyId);
356
+ const raw = storage?.getItem(from);
357
+ if (!raw) continue;
358
+ const to = storageKey(storageName(name), writeKey);
359
+ const current = storage.getItem(to);
360
+ storage.setItem(
361
+ to,
362
+ JSON.stringify(
363
+ merge(JSON.parse(raw), current ? JSON.parse(current) : null),
364
+ ),
365
+ );
366
+ storage.removeItem(from);
367
+ } catch {
368
+ /* unreadable or full: left where it was */
369
+ }
370
+ }
371
+ }
372
+
312
373
  export function createAnalytics(options) {
313
374
  const apiHost = resolveApiHost(options?.apiHost);
314
375
  if (!apiHost)
315
376
  throw new Error(
316
377
  'createAnalytics: apiHost, when given, must be a host or an http(s) origin',
317
378
  );
318
- if (!options?.propertyId)
319
- throw new Error('createAnalytics: propertyId is required');
379
+ if (options?.propertyId !== null && !options?.propertyId)
380
+ throw new Error(
381
+ 'createAnalytics: propertyId is required (null: the ingest stamps the key’s)',
382
+ );
320
383
 
321
384
  const cfg = {
322
385
  libraryName: LIB,
@@ -341,10 +404,12 @@ export function createAnalytics(options) {
341
404
  cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
342
405
  const granted = () => !strict || gate?.status === 'granted';
343
406
 
344
- // Opt-in (3.5): events held for an unanswered CMP survive a navigation, in sessionStorage.
407
+ adoptPropertyScopedBuffers(cfg.propertyId, cfg.writeKey);
408
+ // Opt-in (3.5): events held for an unanswered CMP survive a navigation, in sessionStorage. Per
409
+ // write key, as the retry buffer: what they are sent with.
345
410
  const heldStore =
346
411
  strict && cfg.holdAcrossPages
347
- ? createHeldStore(storageKey(storageName('held'), cfg.propertyId))
412
+ ? createHeldStore(storageKey(storageName('held'), cfg.writeKey))
348
413
  : null;
349
414
  const carried = heldStore?.read() ?? { items: [], anonymousId: null };
350
415
 
@@ -369,6 +434,28 @@ export function createAnalytics(options) {
369
434
  storageName('preview'),
370
435
  cfg.consentCookieDomain ?? null,
371
436
  );
437
+ /** The session kept on the device (the host's, or the SDK's cookie); null while in memory only. */
438
+ const keptSession = () =>
439
+ realId(perRequest(hostSession, null)) ?? getCookie(storageName('session'));
440
+ /**
441
+ * The tags the page loaded with, read now so a later `replaceState` cannot take them away, and
442
+ * the visit's campaign kept from them when the visit has none (visit-campaign.js).
443
+ */
444
+ const landingCampaign = visitCampaign(
445
+ typeof location === 'undefined' ? null : readCampaign(location.search),
446
+ );
447
+ /**
448
+ * A page or an event of the visit, as it is collected whatever consent will say of sending it: the
449
+ * visit's campaign lasts as its session does. A trader's preview is not a shopper's visit: it
450
+ * reads its own URL's tags and keeps none.
451
+ */
452
+ const keepCampaign = (landing = null) => {
453
+ if (!previewing)
454
+ keepVisitCampaign(cfg.propertyId, landing, {
455
+ sessionId: keptSession(),
456
+ keepSession: granted(),
457
+ });
458
+ };
372
459
 
373
460
  /** Delivery problems, logged with `debug`. */
374
461
  const reportError = (message, err) =>
@@ -379,9 +466,9 @@ export function createAnalytics(options) {
379
466
  new Transport({
380
467
  apiHost: cfg.apiHost,
381
468
  writeKey: cfg.writeKey,
382
- // Per tenant: a persisted batch is replayed through whichever instance starts next, with its
383
- // writeKey, and one origin can serve several tenants.
384
- storageKey: storageKey(storageName('retry'), cfg.propertyId),
469
+ // Per write key, which a replay is sent with: one origin can serve several tenants, and a page
470
+ // started without its property (BUILT_IN settings) is replayed by the next one, which has it.
471
+ storageKey: storageKey(storageName('retry'), cfg.writeKey),
385
472
  // Under strict consent, persisting is a device write and replaying is a send: both wait for a
386
473
  // grant (see onStatus below, which replays on a grant and clears on a refusal).
387
474
  mayPersist: strict ? () => gate?.status === 'granted' : undefined,
@@ -432,6 +519,12 @@ export function createAnalytics(options) {
432
519
  let identifiedUserId = null;
433
520
  /** The user bootstrap's `identity` option named, only to warn when the host names another form. */
434
521
  let optionsUserId = null;
522
+ /**
523
+ * The signed-in user's identity token (ADR 0067), the one copy of it: from the `identityToken`
524
+ * option for the user `getUserId` named, then whatever `identify` passes for its user. Another
525
+ * user, `reset()` or a sign-out clears it, so nobody else's token outlives their sign-in.
526
+ */
527
+ let identityToken = userId ? tokenOf(cfg.identityToken) : null;
435
528
 
436
529
  /** The call being replayed: its first event keeps the messageId and time it was made with. */
437
530
  let replaying = null;
@@ -464,6 +557,7 @@ export function createAnalytics(options) {
464
557
  /** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
465
558
  function emit(partial, ctxOverrides, force = false) {
466
559
  noteBasket(partial);
560
+ keepCampaign();
467
561
  try {
468
562
  return compose(partial, ctxOverrides, force);
469
563
  } catch (err) {
@@ -608,6 +702,7 @@ export function createAnalytics(options) {
608
702
  exposedTokens.clear();
609
703
  device?.forget();
610
704
  forgetKeptVisit(true);
705
+ clearVisitCampaign();
611
706
  }
612
707
 
613
708
  function deliver(event, force) {
@@ -644,6 +739,8 @@ export function createAnalytics(options) {
644
739
  };
645
740
  };
646
741
 
742
+ // Before the gate starts, so a withdrawal it finds on this page clears the landing tags too.
743
+ keepCampaign(landingCampaign);
647
744
  if (strict) {
648
745
  gate = createConsentGate({
649
746
  adapter: cfg.consent,
@@ -701,7 +798,10 @@ export function createAnalytics(options) {
701
798
  if (state?.personalisation !== true)
702
799
  forgetKeptVisit(previous?.personalisation === true);
703
800
  // Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
704
- if (withdrawal) transport.discardQueued?.();
801
+ if (withdrawal) {
802
+ transport.discardQueued?.();
803
+ clearVisitCampaign();
804
+ }
705
805
  emit(
706
806
  {
707
807
  type: 'track',
@@ -715,6 +815,8 @@ export function createAnalytics(options) {
715
815
  },
716
816
  });
717
817
  gate.start();
818
+ // Only what survived the gate: a visitor already granted keeps the session id with it.
819
+ keepCampaign();
718
820
  }
719
821
 
720
822
  /** The property's holdout split from the settings, as a serving response would state it. */
@@ -802,6 +904,7 @@ export function createAnalytics(options) {
802
904
  userId = null;
803
905
  identifiedUserId = null;
804
906
  optionsUserId = null;
907
+ identityToken = null;
805
908
  return event;
806
909
  }
807
910
 
@@ -846,8 +949,17 @@ export function createAnalytics(options) {
846
949
  identify(traits, ctx, source) {
847
950
  const { userId: id, ...rest } =
848
951
  traits && typeof traits === 'object' ? traits : {};
952
+ // The token the site's server signed for this user (ADR 0067): without it the identity graph
953
+ // records the sign-in and never joins this device to the user's others.
954
+ const passed = tokenOf(contextOverrides(ctx)?.identity_token);
849
955
  if (id !== undefined) {
850
956
  const next = realId(id);
957
+ // Another user's sign-in brings its own token or none; the same user's keeps the last.
958
+ identityToken = !next
959
+ ? null
960
+ : next === userId
961
+ ? (passed ?? identityToken)
962
+ : passed;
851
963
  if (source === IDENTITY_OPTION) {
852
964
  optionsUserId = next;
853
965
  } else if (identifiedUserId && next && next !== identifiedUserId) {
@@ -867,10 +979,12 @@ export function createAnalytics(options) {
867
979
  }
868
980
  userId = next;
869
981
  if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
870
- }
982
+ } else if (passed && userId) identityToken = passed;
983
+ // Only the current user's token is sent: one passed with nobody to keep it for goes nowhere.
984
+ const { identity_token: _, ...context } = contextOverrides(ctx) ?? {};
871
985
  return emit(
872
986
  { type: 'identify', traits: identifyTraits(rest, cfg.debug) },
873
- ctx,
987
+ identityToken ? { ...context, identity_token: identityToken } : context,
874
988
  );
875
989
  },
876
990
 
@@ -884,6 +998,7 @@ export function createAnalytics(options) {
884
998
  userId = null;
885
999
  identifiedUserId = null;
886
1000
  optionsUserId = null;
1001
+ identityToken = null;
887
1002
  writeLastPageMessageId('');
888
1003
  },
889
1004
 
@@ -1063,6 +1178,8 @@ export function createAnalytics(options) {
1063
1178
  preview: previewing,
1064
1179
  anonymousId: () => realId(perRequest(cfg.getAnonymousId, null)),
1065
1180
  sessionId: () => realId(perRequest(cfg.getSessionId, null)),
1181
+ // Only where it is used: a reminder's code is answered with personalisation granted.
1182
+ identityToken: () => (personalisationGranted() ? identityToken : null),
1066
1183
  analytics: granted,
1067
1184
  personalisation: personalisationGranted,
1068
1185
  consent: () => cfg.getConsent(),
@@ -1072,6 +1189,11 @@ export function createAnalytics(options) {
1072
1189
  ? null
1073
1190
  : scrubHref(location.href, cfg),
1074
1191
  locale: () => localeTag(perRequest(cfg.getLocale, cfg.locale)),
1192
+ /** The visit's campaign for "arrived from"; a preview's is its own URL's. */
1193
+ campaign: () =>
1194
+ previewing
1195
+ ? landingCampaign
1196
+ : readVisitCampaign(cfg.propertyId, { sessionId: keptSession() }),
1075
1197
  /**
1076
1198
  * A basket bar's progress: the basket this page reported, else the last one this visit
1077
1199
  * kept (under personalisation consent), else null.
@@ -1080,6 +1202,12 @@ export function createAnalytics(options) {
1080
1202
  const value = getSessionSignals().basketValue;
1081
1203
  return pageBasket ?? (Number.isFinite(value) ? value : null);
1082
1204
  },
1205
+ /** This visit's session signals, kept only under personalisation consent; else null. */
1206
+ sessionSignals: () => {
1207
+ if (!personalisationGranted()) return null;
1208
+ scopeVisit();
1209
+ return getSessionSignals();
1210
+ },
1083
1211
  /** Calls `listener` whenever the page reports its basket; answers how to stop. */
1084
1212
  onBasket: (listener) => {
1085
1213
  basketListeners.add(listener);
@@ -1098,6 +1226,10 @@ export function createAnalytics(options) {
1098
1226
  },
1099
1227
  };
1100
1228
  },
1229
+ /** bootstrap: the settings could not be fetched, so the ingest on the same host is taken as down. */
1230
+ [UNREACHABLE]() {
1231
+ transport.markFailing?.();
1232
+ },
1101
1233
  /** `hp` and bootstrap replay a call made before the start through this. Not public API. */
1102
1234
  [REPLAY](method, args, made) {
1103
1235
  if (typeof this[method] !== 'function') return;
package/src/onsite/api.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The five calls a form makes, each authenticated with the page's publishable key and never with a
2
+ * The calls a form makes, each authenticated with the page's publishable key and never with a
3
3
  * cookie: `POST /v1/decisions` and `POST /v1/offers/claim` on the public API (with `hp-consent`, as
4
- * every serving request), `GET /v1/capture/challenge`, `POST /v1/capture` and
5
- * `POST /v1/capture/answers` on the ingest gateway.
4
+ * every serving request), `GET /v1/audiences/datafile` there for a form limited to audiences,
5
+ * `GET /v1/capture/challenge`, `POST /v1/capture` and `POST /v1/capture/answers` on the ingest
6
+ * gateway.
6
7
  * Every call resolves, to `{ status, body }` (status 0 when the network failed).
7
8
  */
8
9
 
@@ -16,6 +17,8 @@ const challengePath = (path) =>
16
17
 
17
18
  /** How long one claim request may take before it counts as failed. */
18
19
  const CLAIM_TIMEOUT_MS = 8000;
20
+ /** How long a limited form waits for the audience datafile before it is decided without a claim. */
21
+ const DATAFILE_TIMEOUT_MS = 1500;
19
22
 
20
23
  /**
21
24
  * @param {object} access What the client exposes to ./onsite (index.js, ONSITE_ACCESS).
@@ -57,8 +60,13 @@ export function createApi(access, options = {}) {
57
60
  }
58
61
 
59
62
  return {
60
- /** The decision for one slot, as the page's other serving calls ask for it, with its pin. */
61
- decide: (slot, pin) =>
63
+ /**
64
+ * The decision for one slot, as the page's other serving calls ask for it, with its pin and
65
+ * what the browser knows of the visit: for a form limited to audiences, `visitAudiences`, the
66
+ * session audiences it meets; for a form that names a campaign, `visitCampaign`, the campaign
67
+ * the visit arrived from. The server holds either form back unless its rules hold.
68
+ */
69
+ decide: (slot, pin, { visitAudiences, visitCampaign } = {}) =>
62
70
  call(`${serving}/v1/decisions`, {
63
71
  method: 'POST',
64
72
  consent: true,
@@ -73,9 +81,18 @@ export function createApi(access, options = {}) {
73
81
  ],
74
82
  identifier_value: access.anonymousId() ?? undefined,
75
83
  session_id: access.sessionId() ?? undefined,
84
+ // What a promo reminder's code is answered for under a publishable key (ADR 0067).
85
+ identity_token: access.identityToken?.() ?? undefined,
76
86
  ...(access.preview ? { preview: access.preview } : {}),
87
+ ...(visitAudiences ? { visit_audiences: visitAudiences } : {}),
88
+ ...(visitCampaign ? { visit_campaign: visitCampaign } : {}),
77
89
  },
78
90
  }),
91
+ /** The property's audience definitions: the same for everybody, so no consent header. */
92
+ datafile: () =>
93
+ call(`${serving}/v1/audiences/datafile`, {
94
+ timeoutMs: DATAFILE_TIMEOUT_MS,
95
+ }),
79
96
  challenge: () => call(`${gateway}${challengePath(options.challenge)}`),
80
97
  capture: (body) => call(`${gateway}/v1/capture`, { method: 'POST', body }),
81
98
  // Answers given after the email step, with its address and receipt.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The session-rule evaluator, only for a form limited to audiences: a bundler splits this import
3
+ * out of ./onsite, and the CDN build swaps this module for cdn/audiences-loader.js, which loads
4
+ * dist/onsite-audiences.min.js.
5
+ */
6
+ export const loadAudiences = () => import('./visit-audiences.js');
@@ -2,8 +2,8 @@
2
2
  * What the caps count, per slot, as opaque numbers: shows this session (sessionStorage), and shows
3
3
  * ever, when it was last closed and whether it was submitted (localStorage), plus one subscribed
4
4
  * flag, whether a teaser's tab is out, when a covering form last showed and the version an A/B test
5
- * pinned for the visit (sessionStorage), and the
6
- * last code this browser claimed, for a promo reminder. Never an address, a number or an answer.
5
+ * pinned for the visit (sessionStorage), and the last code this browser claimed, for a promo
6
+ * reminder. Never an address, a number or an answer.
7
7
  * Every access is wrapped: without storage the caps hold for the page view only.
8
8
  *
9
9
  * The counts follow what the shopper did (saw, closed, signed up) and serve only the caps: UI
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { loadAnswers } from './answers-loader.js';
21
21
  import { createApi } from './api.js';
22
+ import { loadAudiences } from './audiences-loader.js';
22
23
  import { createCounters } from './counters.js';
23
24
  import { loadRender } from './render-loader.js';
24
25
  import {
@@ -63,6 +64,8 @@ const INERT = {
63
64
  code: () => null,
64
65
  };
65
66
 
67
+ /** The longest a limited form waits for the evaluator and the datafile before it is decided without. */
68
+ const CLAIMS_MS = 1500;
66
69
  /** How long a fired trigger waits for others, so two forms entering together show the higher priority. */
67
70
  const FIRE_WINDOW_MS = 50;
68
71
  /** The longest a form waits for its first step's pictures before it opens without them. */
@@ -103,6 +106,13 @@ export function startOnsite(client, options = {}) {
103
106
  const counters = access.preview
104
107
  ? INERT
105
108
  : createCounters(access.storageKey, access.personalisation);
109
+ /**
110
+ * The campaign this visit arrived from, the landing page's tags (GA4's session source, and the
111
+ * warehouse's): kept for the visit from the first page that has them. A preview reads this page's
112
+ * own, so a trader's test link shows the form its tags would.
113
+ */
114
+ const campaign = () => access.campaign?.() ?? null;
115
+
106
116
  const mounted = new Map();
107
117
  const suppressed = () => window[SUPPRESSED]?.size > 0;
108
118
 
@@ -131,6 +141,18 @@ export function startOnsite(client, options = {}) {
131
141
  reasons: ['not a form this SDK can show'],
132
142
  });
133
143
  }
144
+ /** The session-rule evaluator, for a limited form; null when its chunk cannot be loaded. */
145
+ const audiencesChunk = () =>
146
+ loadAudiences().catch((err) => {
147
+ console.warn('[hp] onsite audiences not loaded:', err?.message ?? err);
148
+ return null;
149
+ });
150
+ // Warmed with the datafile now, so a limited form that fires waits for neither.
151
+ if (
152
+ access.personalisation() &&
153
+ forms.some((form) => form.audiences.length > 0)
154
+ )
155
+ audiencesChunk().then((chunk) => chunk?.prefetch(api));
134
156
  /**
135
157
  * The covering forms on screen or being opened, by slot (a bar and a popup the shopper asked for
136
158
  * can both be up), the slots being opened now, and the form that spent this page view's allowance.
@@ -199,6 +221,7 @@ export function startOnsite(client, options = {}) {
199
221
  const env = (form) => ({
200
222
  path: location.pathname,
201
223
  device,
224
+ campaign: campaign(),
202
225
  now: Date.now(),
203
226
  seen: counters.seen(form.slot),
204
227
  subscribed: counters.subscribed() || options.subscribed?.() === true,
@@ -285,7 +308,35 @@ export function startOnsite(client, options = {}) {
285
308
  console.warn('[hp] onsite renderer not loaded:', err?.message ?? err);
286
309
  return null;
287
310
  });
288
- const res = await api.decide(form.slot, counters.pin(form.slot));
311
+ // The server decides a limited form's audiences; the browser tells it which session rules hold,
312
+ // and which the page gave. Within CLAIMS_MS, else only the page's: the server decides from what
313
+ // it holds.
314
+ // The evaluator orders the page's own with the visit's (targets and what they require first);
315
+ // a fallback sends the page's alone, the form's targets first and capped at the server's 100.
316
+ const page = sessionAudiences() ?? [];
317
+ const given = [...new Set([...form.audiences, ...page])]
318
+ .filter((slug) => page.includes(slug))
319
+ .slice(0, 100);
320
+ const claimed =
321
+ form.audiences.length > 0 && !access.preview && access.personalisation()
322
+ ? await Promise.race([
323
+ audiencesChunk().then((chunk) =>
324
+ chunk
325
+ ? chunk.visitAudiences(
326
+ api,
327
+ access.sessionSignals,
328
+ form.audiences,
329
+ page,
330
+ )
331
+ : given,
332
+ ),
333
+ new Promise((resolve) => setTimeout(resolve, CLAIMS_MS, given)),
334
+ ])
335
+ : null;
336
+ const res = await api.decide(form.slot, counters.pin(form.slot), {
337
+ visitAudiences: claimed,
338
+ visitCampaign: form.arrived_from.length ? campaign() : null,
339
+ });
289
340
  const decision =
290
341
  res.status === 200
291
342
  ? res.body?.decisions?.find((d) => d?.slot === form.slot)
@@ -56,6 +56,8 @@ const COPY = {
56
56
  claimed: 'This email address has already claimed its code.',
57
57
  copied: 'Copied',
58
58
  thanks: 'Thanks, you’re signed up',
59
+ retry: 'You’re signed up, but we couldn’t load your prize. Please try again.',
60
+ unclaimed: 'We couldn’t load your prize right now.',
59
61
  unavailable: 'Not available right now.',
60
62
  won: 'You won',
61
63
  };
@@ -404,6 +406,9 @@ export function mountForm(o) {
404
406
  warmed: o.warmed ?? new Set(),
405
407
  warming: false,
406
408
  owed: false,
409
+ // Whether the last claim failed (said where the code would be), and how many spins it failed.
410
+ unclaimed: false,
411
+ misses: 0,
407
412
  submitted: false,
408
413
  pow: null,
409
414
  wheel: null,
@@ -651,9 +656,19 @@ export function mountForm(o) {
651
656
  function fillCodes() {
652
657
  for (const box of state.codes) {
653
658
  box.replaceChildren();
654
- if (!state.claim) continue;
659
+ if (!state.claim) {
660
+ if (state.unclaimed) box.append(el('p', '', COPY.unclaimed));
661
+ continue;
662
+ }
655
663
  if (!state.claim.code) {
656
- box.append(el('p', '', state.claim.held ? COPY.claimed : COPY.code));
664
+ // A code still to come only when every pool was out (`fallback`); a prize with no pool
665
+ // ("Try again") gives none, so it says its own words and nothing about a code.
666
+ const said = state.claim.held
667
+ ? COPY.claimed
668
+ : state.claim.fallback === true
669
+ ? COPY.code
670
+ : state.claim.prize?.label;
671
+ if (said) box.append(el('p', '', said));
657
672
  continue;
658
673
  }
659
674
  // The prize the code is for, beside it: "You won!" alone does not say what.
@@ -1206,6 +1221,7 @@ export function mountForm(o) {
1206
1221
  if (channel === 'email')
1207
1222
  state.signedUp = { email: value, email_receipt: state.receipt };
1208
1223
  state.submitted = true;
1224
+ // Subscribed and done with this form, whatever its claim does: a shopper is never asked twice.
1209
1225
  counters.submitted(form.slot);
1210
1226
  engaged('submit', step.id);
1211
1227
  // A form with prizes claims its code now, whether or not the shopper goes on to spin, so the
@@ -1246,27 +1262,36 @@ export function mountForm(o) {
1246
1262
  * The creative's step `id`, else the SDK's own plain one: a thanks with no prize (`fallback`); a
1247
1263
  * code this browser claimed on another form, with that code (`held`); or, for an address another
1248
1264
  * sign-up claimed with (`held`, no code), a welcome back. That code is never shown here: the
1249
- * sign-up hands it to the destinations again, so the inbox has it when one carries it.
1265
+ * sign-up hands it to the destinations again, so the inbox has it when one carries it. Or a claim
1266
+ * that kept failing (`unclaimed`): the sign-up stands and the prize could not be loaded, so it
1267
+ * thanks the shopper and says so, as Klaviyo's and Privy's wheels do, rather than an error.
1250
1268
  */
1251
1269
  function ownStep(id) {
1252
1270
  if (creative.steps.some((s) => s.id === id)) return id;
1271
+ const shop = { t: 'button', label: COPY.shop, action: 'close' };
1253
1272
  const blocks =
1254
- id === 'fallback' || state.claim?.code
1273
+ id === 'unclaimed'
1255
1274
  ? [
1256
- {
1257
- t: 'heading',
1258
- text: id === 'fallback' ? COPY.thanks : COPY.yours,
1259
- },
1260
- { t: 'code' },
1275
+ { t: 'heading', text: COPY.thanks },
1276
+ { t: 'text', text: COPY.unclaimed },
1277
+ shop,
1261
1278
  ]
1262
- : [
1263
- { t: 'heading', text: COPY.held },
1264
- {
1265
- t: 'text',
1266
- text: form.code_relayed === true ? COPY.resent : COPY.inbox,
1267
- },
1268
- { t: 'button', label: COPY.shop, action: 'close' },
1269
- ];
1279
+ : id === 'fallback' || state.claim?.code
1280
+ ? [
1281
+ {
1282
+ t: 'heading',
1283
+ text: id === 'fallback' ? COPY.thanks : COPY.yours,
1284
+ },
1285
+ { t: 'code' },
1286
+ ]
1287
+ : [
1288
+ { t: 'heading', text: COPY.held },
1289
+ {
1290
+ t: 'text',
1291
+ text: form.code_relayed === true ? COPY.resent : COPY.inbox,
1292
+ },
1293
+ shop,
1294
+ ];
1270
1295
  creative.steps.push({ id, blocks });
1271
1296
  return id;
1272
1297
  }
@@ -1284,6 +1309,7 @@ export function mountForm(o) {
1284
1309
  function claim(retries = CLAIM_RETRY_MS) {
1285
1310
  const receipt = state.receipt;
1286
1311
  state.claiming ??= (async () => {
1312
+ state.unclaimed = false;
1287
1313
  let res;
1288
1314
  for (const ms of [0, ...retries]) {
1289
1315
  if (ms) await new Promise((r) => setTimeout(r, ms));
@@ -1302,6 +1328,8 @@ export function mountForm(o) {
1302
1328
  // the page; any other failure is final until a spin asks again.
1303
1329
  if (!retries.length && res.status === 503) return claim();
1304
1330
  settled(null);
1331
+ state.unclaimed = true;
1332
+ if (!state.closed) fillCodes();
1305
1333
  return null;
1306
1334
  }
1307
1335
  // The browser that claimed the code proved it is the shopper's when it won it: a held claim
@@ -1322,9 +1350,12 @@ export function mountForm(o) {
1322
1350
  busy(true);
1323
1351
  const won = await claim();
1324
1352
  if (state.closed) return;
1353
+ // The sign-up stands whatever the claim did: a failed one is offered once more, then the
1354
+ // shopper is thanked and told the prize could not be loaded, never left on an error.
1325
1355
  if (!won) {
1326
1356
  busy(false);
1327
- return say(COPY.failed);
1357
+ state.misses += 1;
1358
+ return state.misses < 2 ? say(COPY.retry) : show(ownStep('unclaimed'));
1328
1359
  }
1329
1360
  const fallback = won.fallback === true && !won.code;
1330
1361
  // A code the address claimed on another form is that form's prize: the wheel lands only on