@thg-agentic/hyper-personalisation-web 3.25.0 → 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.
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
+ }
package/src/shape.js CHANGED
@@ -107,6 +107,10 @@ export function realId(value) {
107
107
  return PLACEHOLDER_IDS.has(text.toLowerCase()) ? null : text;
108
108
  }
109
109
 
110
+ /** An identity token (ADR 0067), or null for anything but a non-empty string. */
111
+ export const tokenOf = (value) =>
112
+ typeof value === 'string' && value !== '' ? value : null;
113
+
110
114
  /** A random UUID: event messageIds and the SDK's own device id. */
111
115
  export function uuid() {
112
116
  if (typeof crypto !== 'undefined' && crypto.randomUUID)
@@ -1,4 +1,4 @@
1
1
  // Generated from the spec by scripts/build-types.mjs. DO NOT EDIT: run `pnpm run build:types`.
2
2
 
3
3
  /** The event specification this SDK emits, stamped on every event as context.spec.version. */
4
- export const SPEC_VERSION = '1.18';
4
+ export const SPEC_VERSION = '1.20';
@@ -64,6 +64,12 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
64
64
  * last closed, submitted) and a subscribed flag. Never contact data (onsite).
65
65
  */
66
66
  onsite: 'thg_hp_onsite',
67
+ /**
68
+ * localStorage, per property: the campaign tags the visit landed with, when it was last active
69
+ * and its session id once kept, ended by 30 minutes without a page or event. Never a device or
70
+ * person id (3.26).
71
+ */
72
+ visitCampaign: 'thg_hp_visit_campaign',
67
73
  });
68
74
 
69
75
  /**
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.25.0';
1
+ export const VERSION = '3.26.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The campaign a visit arrived from: the landing page's `utm_*` tags, as GA4's session source and
3
+ * the warehouse's `profiles.sessions` attribute a session to its landing page view. Judged in the
4
+ * browser, and sent only with the decision request for a form with rules, never logged.
5
+ *
6
+ * One store per property, owned here and written by the client as it starts: localStorage, so every
7
+ * tab of the visit reads the same campaign. The first tagged page of a visit wins; the visit ends,
8
+ * as the SDK's session does, after SESSION_TIMEOUT_MS without a page or an event, or when the session
9
+ * it was kept under is replaced. It holds the tags, when the visit was last active and the session
10
+ * id once one is kept on the device: never a device or person id. Kept before consent as the onsite
11
+ * counters are (ICO, the strictly necessary exception): it is context about this visit, which the
12
+ * site's own forms need on the next page.
13
+ */
14
+
15
+ import { SESSION_TIMEOUT_MS } from './session-id.js';
16
+ import { storageKey, storageName } from './storage-scope.js';
17
+
18
+ /** The longest a tag is read: the control plane's CAMPAIGN_VALUE_MAX, which the server cuts to. */
19
+ const TAG_MAX = 100;
20
+
21
+ export const CAMPAIGN_FIELDS = [
22
+ 'source',
23
+ 'medium',
24
+ 'campaign',
25
+ 'content',
26
+ 'term',
27
+ ];
28
+
29
+ /**
30
+ * `{ source, medium, campaign, content, term }` from `context.campaign` (`readCampaign`, which names
31
+ * `utm_campaign` `name`), only the tags present, each cut to TAG_MAX characters; null without a single `utm_*` (a click id alone is
32
+ * not a campaign).
33
+ *
34
+ * @param {Record<string, string> | null | undefined} campaign
35
+ */
36
+ export function visitCampaign(campaign) {
37
+ if (!campaign || typeof campaign !== 'object') return null;
38
+ const out = {};
39
+ for (const field of CAMPAIGN_FIELDS) {
40
+ const value = campaign[field === 'campaign' ? 'name' : field];
41
+ if (typeof value === 'string' && value)
42
+ out[field] = value.slice(0, TAG_MAX);
43
+ }
44
+ return Object.keys(out).length ? out : null;
45
+ }
46
+
47
+ const norm = (v) => String(v).trim().toLowerCase();
48
+
49
+ /**
50
+ * Whether `campaign` (a `visitCampaign`) meets every rule `{ field, match: 'is' | 'contains',
51
+ * values }`, any value meeting a rule, in any case. No rules: true. No campaign, or a rule this SDK
52
+ * cannot read: false.
53
+ */
54
+ export function arrivedFrom(rules, campaign) {
55
+ return (Array.isArray(rules) ? rules : []).every((rule) => {
56
+ const tag = campaign?.[rule?.field];
57
+ if (!CAMPAIGN_FIELDS.includes(rule?.field) || typeof tag !== 'string')
58
+ return false;
59
+ const have = norm(tag);
60
+ return [].concat(rule.values ?? []).some((v) => {
61
+ const want = norm(v);
62
+ return rule.match === 'is'
63
+ ? have === want
64
+ : rule.match === 'contains' && want !== '' && have.includes(want);
65
+ });
66
+ });
67
+ }
68
+
69
+ const keyOf = (propertyId) =>
70
+ storageKey(
71
+ storageName('visitCampaign'),
72
+ propertyId == null ? null : String(propertyId),
73
+ );
74
+
75
+ /**
76
+ * What this page kept, by key, for a browser whose localStorage is missing or refused the last
77
+ * write: the campaign then holds for the page, read and expired as the stored one is.
78
+ */
79
+ const memory = new Map();
80
+
81
+ function stored(key) {
82
+ if (memory.has(key)) return memory.get(key);
83
+ try {
84
+ const kept = JSON.parse(localStorage.getItem(key));
85
+ return kept && typeof kept === 'object' ? kept : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function put(key, value) {
92
+ try {
93
+ if (value) localStorage.setItem(key, JSON.stringify(value));
94
+ else localStorage.removeItem(key);
95
+ memory.delete(key);
96
+ } catch {
97
+ if (value) memory.set(key, value);
98
+ else memory.delete(key);
99
+ }
100
+ }
101
+
102
+ /** The kept visit when it is still this one: active within the timeout, and under this session. */
103
+ function live(kept, sessionId, now) {
104
+ return typeof kept?.tags === 'object' &&
105
+ kept.tags !== null &&
106
+ Number.isFinite(kept.at) &&
107
+ now - kept.at < SESSION_TIMEOUT_MS &&
108
+ !(sessionId && kept.sid && kept.sid !== sessionId)
109
+ ? kept
110
+ : null;
111
+ }
112
+
113
+ /**
114
+ * The campaign this visit arrived from (a `visitCampaign`), or null; reads only. `sessionId` is the
115
+ * session kept on the device (the host's, or the SDK's cookie), null while it lives in memory.
116
+ *
117
+ * @param {string|null|undefined} propertyId
118
+ * @param {{ sessionId?: string|null, now?: number }} [options]
119
+ */
120
+ export function readVisitCampaign(
121
+ propertyId,
122
+ { sessionId = null, now = Date.now() } = {},
123
+ ) {
124
+ return live(stored(keyOf(propertyId)), sessionId, now)?.tags ?? null;
125
+ }
126
+
127
+ /**
128
+ * A page or an event of the visit: the kept campaign stays, active from `now`; a visit with none
129
+ * takes `landing`, this page's tags, when it has any. Answers the visit's campaign. `sessionId` is
130
+ * compared with the kept one; it is stored only with `keepSession` (analytics granted).
131
+ *
132
+ * @param {string|null|undefined} propertyId
133
+ * @param {Record<string, string>|null} landing This page's `visitCampaign`, or null.
134
+ * @param {{ sessionId?: string|null, keepSession?: boolean, now?: number }} [options]
135
+ */
136
+ export function keepVisitCampaign(
137
+ propertyId,
138
+ landing,
139
+ { sessionId = null, keepSession = false, now = Date.now() } = {},
140
+ ) {
141
+ const key = keyOf(propertyId);
142
+ const raw = stored(key);
143
+ const kept = live(raw, sessionId, now);
144
+ const tags = kept?.tags ?? landing ?? null;
145
+ // A session id is added only while analytics is granted, as the session cookie is; one kept
146
+ // under an earlier grant stays while consent is pending, so a new session still ends the visit.
147
+ // Withdrawal clears the store (`clearVisitCampaign`).
148
+ if (tags)
149
+ put(key, {
150
+ tags,
151
+ at: now,
152
+ sid: (keepSession ? sessionId : null) ?? kept?.sid ?? null,
153
+ });
154
+ else if (raw) put(key, null);
155
+ return tags;
156
+ }
157
+
158
+ /** Forget every property's visit campaign on this origin: a sign-out, or consent withdrawn. */
159
+ export function clearVisitCampaign() {
160
+ memory.clear();
161
+ try {
162
+ const prefix = `${storageName('visitCampaign')}:`;
163
+ for (const key of Object.keys(localStorage))
164
+ if (key.startsWith(prefix)) localStorage.removeItem(key);
165
+ } catch {
166
+ /* nothing was kept */
167
+ }
168
+ }
@@ -88,6 +88,13 @@ export interface Preset extends DataLayerPreset {
88
88
  export interface Identity {
89
89
  userId?: string | number | null;
90
90
  emailSha256?: string | null;
91
+ /**
92
+ * The identity token your server signed for this user on this page view (ADR 0067): a JWT,
93
+ * ES256 with `typ: "JWT"` over `{ sub: userId, aud: "hyper-personalisation", exp }`. Without
94
+ * it the sign-in is recorded but never joins this device to the user's others, and a promo
95
+ * reminder's code is not shown. Since 3.26.
96
+ */
97
+ token?: string | null;
91
98
  }
92
99
 
93
100
  export interface AutocaptureOptions {
package/types/events.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Generated from spec/registry.json by scripts/generate-sdk-models.mjs.
2
2
  // DO NOT EDIT. Change the schema and regenerate — the schema is the contract,
3
3
  // and a hand-edit here is an SDK that disagrees with the ingest.
4
- // Spec version: 1.18
4
+ // Spec version: 1.20
5
5
 
6
6
  /** context.source.environment. Environment the producer runs in. */
7
7
  export type CdpSourceEnvironment = 'production' | 'staging' | 'development';
@@ -91,6 +91,32 @@ export interface JourneyTriggered {
91
91
  products?: CdpProduct[];
92
92
  }
93
93
 
94
+ /** The serving log: one ranked rail as the recommender answered it, with the features it ranked on, recorded when it was served rather than recomputed later. */
95
+ export interface RecommendationServed {
96
+ recommendation_request_id: string;
97
+ group: 'personalised' | 'holdout' | 'excluded';
98
+ personalised: boolean;
99
+ products: Record<string, unknown>[];
100
+ batch_request_id?: string | null;
101
+ placement?: string | null;
102
+ recipe_requested?: string | null;
103
+ recipe?: string | null;
104
+ strategy?: string | null;
105
+ algorithm_version?: string | null;
106
+ ranked_by?: 'ranker' | 'blend' | 'session' | 'manual' | 'other' | null;
107
+ fusion?: string | null;
108
+ fast_weight?: number | null;
109
+ found?: boolean | null;
110
+ degraded?: boolean | null;
111
+ person_id?: string | null;
112
+ decision_id?: string | null;
113
+ arm?: string | null;
114
+ propensity?: number;
115
+ limit?: number | null;
116
+ candidate_set_size?: number | null;
117
+ unserved?: Record<string, unknown>[] | null;
118
+ }
119
+
94
120
  /** A visitor acted on a personalised surface: clicked it (for example opened a product from a rail), dismissed it, or submitted a step of an onsite form. */
95
121
  export interface SlotEngaged {
96
122
  decision_id: string;
@@ -686,12 +712,13 @@ export interface SignUpRelayed {
686
712
  }
687
713
 
688
714
  /** The spec version these models implement. SDKs stamp it on context.spec.version. */
689
- export const SPEC_VERSION = '1.18';
715
+ export const SPEC_VERSION = '1.20';
690
716
 
691
717
  /** Every event name the registry accepts. Anything else is rejected at the ingest. */
692
718
  export type CdpEventName =
693
719
  | 'Decision Made'
694
720
  | 'Journey Triggered'
721
+ | 'Recommendation Served'
695
722
  | 'Slot Engaged'
696
723
  | 'Recommendations Viewed'
697
724
  | 'Promotion Viewed'
@@ -755,6 +782,7 @@ export type CdpEventName =
755
782
  export interface CdpEventProperties {
756
783
  'Decision Made': DecisionMade;
757
784
  'Journey Triggered': JourneyTriggered;
785
+ 'Recommendation Served': RecommendationServed;
758
786
  'Slot Engaged': SlotEngaged;
759
787
  'Recommendations Viewed': RecommendationsViewed;
760
788
  'Promotion Viewed': PromotionViewed;
package/types/index.d.ts CHANGED
@@ -209,6 +209,12 @@ export interface AnalyticsOptions extends AnalyticsContextSources {
209
209
  */
210
210
  holdAcrossPages?: boolean;
211
211
  getUserId?(): string | null | undefined;
212
+ /**
213
+ * The identity token your server signed for the user `getUserId` names (a JWT, ADR 0067), read
214
+ * once: sent on each identify and with each onsite decision until `reset()`, a sign-out or
215
+ * another user's `identify`. A later sign-in passes its own on `identify`. Since 3.26.
216
+ */
217
+ identityToken?: string | null;
212
218
  /** Observer for every event that will be sent. Must not throw; failures are swallowed. */
213
219
  onEvent?(event: CdpEvent): void;
214
220
 
package/types/onsite.d.ts CHANGED
@@ -47,6 +47,15 @@ export interface OnsiteForm {
47
47
  pages?: { include?: string[]; exclude?: string[] };
48
48
  /** `mobile` is a coarse pointer or a viewport under 768px. Absent: both. */
49
49
  devices?: Array<'desktop' | 'mobile'>;
50
+ /**
51
+ * Shown only to a visit that arrived from a matching campaign: the landing page's `utm_*` tags,
52
+ * kept for the visit. Every rule must hold; any of a rule's values meets it, in any case.
53
+ */
54
+ arrived_from?: Array<{
55
+ field: 'source' | 'medium' | 'campaign' | 'content' | 'term';
56
+ match: 'is' | 'contains';
57
+ values: string[];
58
+ }>;
50
59
  /** Shown only with personalisation consent, and only to members as the server decides. */
51
60
  audiences?: string[];
52
61
  exclude_subscribed?: boolean;