@thg-agentic/hyper-personalisation-web 3.25.0 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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([
@@ -426,6 +434,28 @@ export function createAnalytics(options) {
426
434
  storageName('preview'),
427
435
  cfg.consentCookieDomain ?? null,
428
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
+ };
429
459
 
430
460
  /** Delivery problems, logged with `debug`. */
431
461
  const reportError = (message, err) =>
@@ -489,6 +519,12 @@ export function createAnalytics(options) {
489
519
  let identifiedUserId = null;
490
520
  /** The user bootstrap's `identity` option named, only to warn when the host names another form. */
491
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;
492
528
 
493
529
  /** The call being replayed: its first event keeps the messageId and time it was made with. */
494
530
  let replaying = null;
@@ -521,6 +557,7 @@ export function createAnalytics(options) {
521
557
  /** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
522
558
  function emit(partial, ctxOverrides, force = false) {
523
559
  noteBasket(partial);
560
+ keepCampaign();
524
561
  try {
525
562
  return compose(partial, ctxOverrides, force);
526
563
  } catch (err) {
@@ -604,7 +641,9 @@ export function createAnalytics(options) {
604
641
  }
605
642
 
606
643
  function observe(event) {
607
- if (event.type === 'track') recordVisit(event);
644
+ // A page view folds in no counter, but brings the visit's campaign into the session signals,
645
+ // so a landing page that sends nothing else can resolve an audience on it.
646
+ if (event.type === 'track' || event.type === 'page') recordVisit(event);
608
647
  // Lets a caller fold the event the warehouse will receive into session state. An observer
609
648
  // that throws must not cost the event.
610
649
  if (cfg.onEvent) {
@@ -629,11 +668,15 @@ export function createAnalytics(options) {
629
668
  try {
630
669
  if (personalisationGranted()) {
631
670
  scopeVisit();
632
- keepVisit(
633
- recommendationContextFrom(
634
- recordSessionEvent(event.event, event.properties),
635
- ),
671
+ const signals = recordSessionEvent(
672
+ event.event,
673
+ event.properties,
674
+ // The one visit-campaign store, kept since the landing page whatever the consent then.
675
+ readVisitCampaign(cfg.propertyId, { sessionId: keptSession() }),
636
676
  );
677
+ // A page view changes nothing a rail reads, so it does not extend what is kept for one.
678
+ if (event.type === 'track')
679
+ keepVisit(recommendationContextFrom(signals));
637
680
  visitKept = true;
638
681
  } else {
639
682
  // Not allowed, or withdrawn: what was kept under the permission goes with it.
@@ -665,6 +708,7 @@ export function createAnalytics(options) {
665
708
  exposedTokens.clear();
666
709
  device?.forget();
667
710
  forgetKeptVisit(true);
711
+ clearVisitCampaign();
668
712
  }
669
713
 
670
714
  function deliver(event, force) {
@@ -701,6 +745,8 @@ export function createAnalytics(options) {
701
745
  };
702
746
  };
703
747
 
748
+ // Before the gate starts, so a withdrawal it finds on this page clears the landing tags too.
749
+ keepCampaign(landingCampaign);
704
750
  if (strict) {
705
751
  gate = createConsentGate({
706
752
  adapter: cfg.consent,
@@ -758,7 +804,10 @@ export function createAnalytics(options) {
758
804
  if (state?.personalisation !== true)
759
805
  forgetKeptVisit(previous?.personalisation === true);
760
806
  // Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
761
- if (withdrawal) transport.discardQueued?.();
807
+ if (withdrawal) {
808
+ transport.discardQueued?.();
809
+ clearVisitCampaign();
810
+ }
762
811
  emit(
763
812
  {
764
813
  type: 'track',
@@ -772,6 +821,8 @@ export function createAnalytics(options) {
772
821
  },
773
822
  });
774
823
  gate.start();
824
+ // Only what survived the gate: a visitor already granted keeps the session id with it.
825
+ keepCampaign();
775
826
  }
776
827
 
777
828
  /** The property's holdout split from the settings, as a serving response would state it. */
@@ -859,6 +910,7 @@ export function createAnalytics(options) {
859
910
  userId = null;
860
911
  identifiedUserId = null;
861
912
  optionsUserId = null;
913
+ identityToken = null;
862
914
  return event;
863
915
  }
864
916
 
@@ -903,8 +955,17 @@ export function createAnalytics(options) {
903
955
  identify(traits, ctx, source) {
904
956
  const { userId: id, ...rest } =
905
957
  traits && typeof traits === 'object' ? traits : {};
958
+ // The token the site's server signed for this user (ADR 0067): without it the identity graph
959
+ // records the sign-in and never joins this device to the user's others.
960
+ const passed = tokenOf(contextOverrides(ctx)?.identity_token);
906
961
  if (id !== undefined) {
907
962
  const next = realId(id);
963
+ // Another user's sign-in brings its own token or none; the same user's keeps the last.
964
+ identityToken = !next
965
+ ? null
966
+ : next === userId
967
+ ? (passed ?? identityToken)
968
+ : passed;
908
969
  if (source === IDENTITY_OPTION) {
909
970
  optionsUserId = next;
910
971
  } else if (identifiedUserId && next && next !== identifiedUserId) {
@@ -924,10 +985,12 @@ export function createAnalytics(options) {
924
985
  }
925
986
  userId = next;
926
987
  if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
927
- }
988
+ } else if (passed && userId) identityToken = passed;
989
+ // Only the current user's token is sent: one passed with nobody to keep it for goes nowhere.
990
+ const { identity_token: _, ...context } = contextOverrides(ctx) ?? {};
928
991
  return emit(
929
992
  { type: 'identify', traits: identifyTraits(rest, cfg.debug) },
930
- ctx,
993
+ identityToken ? { ...context, identity_token: identityToken } : context,
931
994
  );
932
995
  },
933
996
 
@@ -941,6 +1004,7 @@ export function createAnalytics(options) {
941
1004
  userId = null;
942
1005
  identifiedUserId = null;
943
1006
  optionsUserId = null;
1007
+ identityToken = null;
944
1008
  writeLastPageMessageId('');
945
1009
  },
946
1010
 
@@ -1120,6 +1184,8 @@ export function createAnalytics(options) {
1120
1184
  preview: previewing,
1121
1185
  anonymousId: () => realId(perRequest(cfg.getAnonymousId, null)),
1122
1186
  sessionId: () => realId(perRequest(cfg.getSessionId, null)),
1187
+ // Only where it is used: a reminder's code is answered with personalisation granted.
1188
+ identityToken: () => (personalisationGranted() ? identityToken : null),
1123
1189
  analytics: granted,
1124
1190
  personalisation: personalisationGranted,
1125
1191
  consent: () => cfg.getConsent(),
@@ -1129,6 +1195,11 @@ export function createAnalytics(options) {
1129
1195
  ? null
1130
1196
  : scrubHref(location.href, cfg),
1131
1197
  locale: () => localeTag(perRequest(cfg.getLocale, cfg.locale)),
1198
+ /** The visit's campaign for "arrived from"; a preview's is its own URL's. */
1199
+ campaign: () =>
1200
+ previewing
1201
+ ? landingCampaign
1202
+ : readVisitCampaign(cfg.propertyId, { sessionId: keptSession() }),
1132
1203
  /**
1133
1204
  * A basket bar's progress: the basket this page reported, else the last one this visit
1134
1205
  * kept (under personalisation consent), else null.
@@ -1137,6 +1208,20 @@ export function createAnalytics(options) {
1137
1208
  const value = getSessionSignals().basketValue;
1138
1209
  return pageBasket ?? (Number.isFinite(value) ? value : null);
1139
1210
  },
1211
+ /**
1212
+ * This visit's session signals, kept only under personalisation consent; else null. The
1213
+ * campaign is the store's as it stands, so one that has lapsed since the last event is gone.
1214
+ */
1215
+ sessionSignals: () => {
1216
+ if (!personalisationGranted()) return null;
1217
+ scopeVisit();
1218
+ return {
1219
+ ...getSessionSignals(),
1220
+ campaign: readVisitCampaign(cfg.propertyId, {
1221
+ sessionId: keptSession(),
1222
+ }),
1223
+ };
1224
+ },
1140
1225
  /** Calls `listener` whenever the page reports its basket; answers how to stop. */
1141
1226
  onBasket: (listener) => {
1142
1227
  basketListeners.add(listener);
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)
@@ -3,6 +3,8 @@
3
3
  * counters, so the eligibility trace (`hp.onsite.debug()`) says exactly what decided.
4
4
  */
5
5
 
6
+ import { arrivedFrom } from '../visit-campaign.js';
7
+
6
8
  export const FORMATS = [
7
9
  'popup',
8
10
  'sheet',
@@ -39,6 +41,11 @@ const strings = (v) =>
39
41
  Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
40
42
  const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
41
43
 
44
+ /** A rule no visit meets: what an `arrived_from` this SDK cannot read becomes, so it fails closed. */
45
+ const NEVER = { field: null, match: null, values: [] };
46
+ const arrivedRules = (raw) =>
47
+ raw == null ? [] : Array.isArray(raw) && raw.every(isObject) ? raw : [NEVER];
48
+
42
49
  /** A form from the settings as the runtime uses it, or null when it cannot be used. */
43
50
  export function readForm(raw) {
44
51
  if (!isObject(raw) || typeof raw.slot !== 'string' || raw.slot === '')
@@ -55,6 +62,7 @@ export function readForm(raw) {
55
62
  devices: strings(raw.devices),
56
63
  aliases: strings(raw.aliases),
57
64
  audiences: strings(raw.audiences),
65
+ arrived_from: arrivedRules(raw.arrived_from),
58
66
  caps: {
59
67
  per_session: count(caps.per_session),
60
68
  lifetime: count(caps.lifetime),
@@ -123,25 +131,32 @@ const at = (iso) => (typeof iso === 'string' ? Date.parse(iso) : Number.NaN);
123
131
  /**
124
132
  * A form the site opens itself (`manual`: its own sign-up button) is the shopper asking for it, so
125
133
  * how often it has shown does not hold it back: no caps, gap or page view allowance, as Klaviyo's
126
- * custom-triggered forms ignore their frequency settings. Its pages, devices, dates and audiences
127
- * still do.
134
+ * custom-triggered forms ignore their frequency settings. Its pages, devices, campaign, dates and
135
+ * audiences still do.
128
136
  */
129
137
  export const ASKED = (form) => form.trigger.type === 'manual';
130
138
 
131
139
  /**
132
140
  * Every reason `form` may not show now, empty when it may. `env` is the page:
133
- * `{ path, device, now, seen, subscribed, personalisation, preview, audiences }`, where `seen` is
134
- * the slot's counters and `audiences` the session audiences the host gave, or null.
141
+ * `{ path, device, campaign, now, seen, subscribed, personalisation, preview, audiences }`, where
142
+ * `campaign` is the visit's (`visitCampaign`), `seen` the slot's counters and `audiences` the
143
+ * session audiences the host gave, or null. The campaign is context about this visit, as the page
144
+ * is, so it needs no personalisation consent. A form limited to audiences needs personalisation
145
+ * consent; whether the visitor is in one is the server's to decide (`POST /v1/decisions`), unless
146
+ * the host gave its own list.
135
147
  */
136
148
  export function blockers(form, env) {
137
149
  const reasons = [];
138
150
  if (!pageAllowed(form.pages, env.path)) reasons.push('page not included');
139
151
  if (form.devices.length && !form.devices.includes(env.device))
140
152
  reasons.push(`not on ${env.device}`);
153
+ if (!arrivedFrom(form.arrived_from, env.campaign))
154
+ reasons.push('not from its campaign');
141
155
  const starts = at(form.window.starts_at);
142
156
  const ends = at(form.window.ends_at);
143
157
  if (env.now < starts || env.now >= ends) reasons.push('outside its dates');
144
- if (form.audiences.length) {
158
+ // A trader previewing a limited form sees it, as the server answers a preview.
159
+ if (form.audiences.length && !env.preview) {
145
160
  if (!env.personalisation)
146
161
  reasons.push('audiences need personalisation consent');
147
162
  else if (
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The session audiences whose rules hold in this visit, for a form limited to audiences (ADR 0064):
3
+ * the server decides membership, but the live tier counts only product views and cart adds, so the
4
+ * browser evaluates the datafile's session rules and sends the slugs as `visit_audiences`. Rules
5
+ * only: `requires`, MIXED and targetability are the server's to apply. A chunk of its own
6
+ * (./audiences-loader.js), loaded with the datafile as the runtime starts, only when a live form is
7
+ * limited and the visitor allowed personalisation.
8
+ */
9
+
10
+ import { catalogueBrowse, sessionRulesHold } from '../session/rules.js';
11
+
12
+ /** What the decide request accepts: more, or a longer slug, and it ignores the lot. */
13
+ const MAX_AUDIENCES = 100;
14
+ /** Past any slug the console makes: a name of at most 100 characters, and a `-2` to tell twins apart. */
15
+ const MAX_SLUG = 128;
16
+
17
+ const isSlug = (slug) =>
18
+ typeof slug === 'string' && slug !== '' && slug.length <= MAX_SLUG;
19
+
20
+ /** The datafile for each onsite runtime's api: a 200 kept for the page, anything else asked again. */
21
+ const datafiles = new WeakMap();
22
+
23
+ /** Start fetching the datafile, so a form that fires later does not wait for it. */
24
+ export function prefetch(api) {
25
+ if (!datafiles.has(api)) {
26
+ const fetching = api
27
+ .datafile()
28
+ .then((res) => (res.status === 200 ? res.body : null))
29
+ .catch(() => null);
30
+ datafiles.set(api, fetching);
31
+ fetching.then((body) => {
32
+ if (!body && datafiles.get(api) === fetching) datafiles.delete(api);
33
+ });
34
+ }
35
+ return datafiles.get(api);
36
+ }
37
+
38
+ /**
39
+ * The slugs to send: those whose rules hold in this visit (the datafile fetched once, the visit read
40
+ * each time) and those the page gave, once each, at most MAX_AUDIENCES of them. Past that, the form's
41
+ * own audiences and what they require go first, so the cap never drops the ones it asks about.
42
+ *
43
+ * @param {{ datafile: () => Promise<{ status: number, body: any }> }} api ./api.js
44
+ * @param {() => object | null | undefined} [signals] This visit's session signals, kept only under
45
+ * personalisation consent.
46
+ * @param {string[]} [targets] The audiences the form is limited to.
47
+ * @param {string[]} [given] The session audiences the page gave (`startOnsite`'s `audiences()`).
48
+ * @returns {Promise<string[]>}
49
+ */
50
+ export async function visitAudiences(api, signals, targets = [], given = []) {
51
+ const datafile = await prefetch(api);
52
+ const visit = signals?.();
53
+ const session = Array.isArray(datafile?.session_audiences)
54
+ ? datafile.session_audiences
55
+ : [];
56
+ const held = datafile && visit ? holdingAudiences(datafile, visit) : [];
57
+ return firstWanted([...held, ...given], targets, session);
58
+ }
59
+
60
+ /** `slugs`, once each, `targets` and their transitive `requires` first, capped. */
61
+ export function firstWanted(slugs, targets, session) {
62
+ const requires = new Map(
63
+ session.map((a) => [a?.slug, Array.isArray(a?.requires) ? a.requires : []]),
64
+ );
65
+ const wanted = new Set();
66
+ const visit = (slug) => {
67
+ if (wanted.has(slug)) return;
68
+ wanted.add(slug);
69
+ for (const required of requires.get(slug) ?? []) visit(required);
70
+ };
71
+ for (const slug of targets) visit(slug);
72
+ const unique = [...new Set(slugs.filter(isSlug))];
73
+ return [
74
+ ...unique.filter((slug) => wanted.has(slug)),
75
+ ...unique.filter((slug) => !wanted.has(slug)),
76
+ ].slice(0, MAX_AUDIENCES);
77
+ }
78
+
79
+ /**
80
+ * @param {{ session_audiences?: unknown, browse_categories?: unknown }} datafile `GET /v1/audiences/datafile`.
81
+ * @param {object} signals This visit's session signals.
82
+ * @returns {string[]}
83
+ */
84
+ export function holdingAudiences(datafile, signals) {
85
+ const audiences = Array.isArray(datafile?.session_audiences)
86
+ ? datafile.session_audiences
87
+ : [];
88
+ const visit = catalogueBrowse(signals, datafile.browse_categories ?? null);
89
+ return audiences
90
+ .filter(
91
+ (audience) => isSlug(audience?.slug) && sessionRulesHold(audience, visit),
92
+ )
93
+ .map((audience) => audience.slug);
94
+ }
@@ -58,11 +58,16 @@ function countListView(s, category) {
58
58
  * Fold one CDP event into the session signals.
59
59
  *
60
60
  * Driven by the events the SDK already sends, so a session rule is only ever about something the
61
- * warehouse will also see.
61
+ * warehouse will also see. `campaign`, when given, is the visit's campaign (`readVisitCampaign`),
62
+ * mirrored as it stands so the two never disagree; left out, the kept one stays.
62
63
  */
63
- export function recordSessionEvent(eventName, properties) {
64
+ export function recordSessionEvent(eventName, properties, campaign) {
64
65
  const s = readSignals();
65
66
  if (!s.startedAt) s.startedAt = Date.now();
67
+ const arrived =
68
+ campaign !== undefined &&
69
+ JSON.stringify(campaign ?? null) !== JSON.stringify(s.campaign ?? null);
70
+ if (arrived) s.campaign = campaign ?? null;
66
71
 
67
72
  const products = Array.isArray(properties?.products)
68
73
  ? properties.products
@@ -178,6 +183,7 @@ export function recordSessionEvent(eventName, properties) {
178
183
  return s;
179
184
  }
180
185
  default:
186
+ if (arrived) writeSignals(s);
181
187
  return s;
182
188
  }
183
189
 
@@ -1,3 +1,4 @@
1
+ import { CAMPAIGN_FIELDS, tagMatches } from '../visit-campaign.js';
1
2
  import { categoryKey } from './category.js';
2
3
  import { readSignals } from './signals.js';
3
4
 
@@ -17,12 +18,28 @@ const OPERATORS = {
17
18
  '=': (a, b) => a === b,
18
19
  };
19
20
 
21
+ /**
22
+ * Operators on a text field (the visit's campaign tags): `tagMatches`, the matcher a form's
23
+ * "arrived from" rule uses, so the two agree. A value of the wrong shape matches nothing.
24
+ */
25
+ const text = (v) => typeof v === 'string';
26
+ const is = (a, b) => text(b) && tagMatches(a, 'is', b);
27
+ const STRING_OPERATORS = {
28
+ eq: is,
29
+ equals: is,
30
+ '=': is,
31
+ neq: (a, b) => text(b) && !is(a, b),
32
+ in: (a, b) => Array.isArray(b) && b.some((v) => is(a, v)),
33
+ contains: (a, b) => text(b) && tagMatches(a, 'contains', b),
34
+ };
35
+
20
36
  /**
21
37
  * Every operator this evaluator can apply, including `between`. A contract test keeps the session
22
38
  * trait catalogue inside this set, since an unsupported operator fails closed silently.
23
39
  */
24
40
  export const SESSION_OPERATORS = new Set([
25
41
  ...Object.keys(OPERATORS),
42
+ ...Object.keys(STRING_OPERATORS),
26
43
  'between',
27
44
  ]);
28
45
 
@@ -54,14 +71,23 @@ const MEASURES = {
54
71
  /**
55
72
  * What a rule's field is worth right now, or null if this build does not know the field.
56
73
  *
57
- * A field is a counter name or `measure.dimension` (`browseShare.damen/schuhe`). Exported so a
58
- * storefront can explain a personalisation. Unknown fields fail the rule closed.
74
+ * A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`), or one of the
75
+ * visit's campaign tags (`campaign.source`), which is text and null for a visit that arrived
76
+ * untagged. Exported so a storefront can explain a personalisation. Unknown fields fail the rule
77
+ * closed.
59
78
  */
60
79
  export function measureField(field, signals) {
61
80
  const separator = field.indexOf('.');
62
81
  if (separator > 0) {
63
- const fn = MEASURES[field.slice(0, separator)];
82
+ const name = field.slice(0, separator);
64
83
  const arg = field.slice(separator + 1);
84
+ if (name === 'campaign') {
85
+ const tag = CAMPAIGN_FIELDS.includes(arg)
86
+ ? signals.campaign?.[arg]
87
+ : null;
88
+ return typeof tag === 'string' ? tag : null;
89
+ }
90
+ const fn = MEASURES[name];
65
91
  return fn && arg ? fn(signals, arg) : null;
66
92
  }
67
93
  const value = signals[field];
@@ -71,6 +97,10 @@ export function measureField(field, signals) {
71
97
  export function evaluateSessionRule(rule, signals) {
72
98
  const actual = measureField(rule.field, signals);
73
99
  if (actual === null) return false;
100
+ if (typeof actual === 'string') {
101
+ const compare = STRING_OPERATORS[rule.operator];
102
+ return compare ? compare(actual, rule.value) : false;
103
+ }
74
104
  if (rule.operator === 'between') {
75
105
  // A `between` missing either bound fails closed.
76
106
  return (