@thg-agentic/hyper-personalisation-web 3.29.0 → 3.31.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.
@@ -422,7 +422,11 @@ export function mountForm(o) {
422
422
  modal: false,
423
423
  mobile: preview?.device === 'mobile',
424
424
  answers: {},
425
+ // The step each answer was given on, by question.
426
+ answeredOn: {},
425
427
  sent: new Set(),
428
+ // Whether this view's click is counted: a view counts one, whatever was clicked.
429
+ clicked: false,
426
430
  // The products blocks' rails reported seen, by id: a step shown again does not report it twice.
427
431
  seen: new Set(),
428
432
  receipt: null,
@@ -481,8 +485,17 @@ export function mountForm(o) {
481
485
  small.addEventListener?.('change', resize);
482
486
  }
483
487
 
488
+ /** A form that collects nothing: a message, products, or a quiz. */
489
+ const asksNothing = !form.channels?.length;
490
+
484
491
  const engaged = (engagement, step, item = {}) => {
485
492
  if (preview || !decision.decision_id || !access.analytics()) return;
493
+ // A view counts one click; a products card's always says which product was clicked.
494
+ if (engagement === 'click') {
495
+ const counted = state.clicked;
496
+ state.clicked = true;
497
+ if (counted && !item.products) return;
498
+ }
486
499
  client.slotEngaged({
487
500
  decision_id: decision.decision_id,
488
501
  slot: decision.slot,
@@ -768,8 +781,9 @@ export function mountForm(o) {
768
781
  node.href = href;
769
782
  node.rel = 'noopener';
770
783
  node.addEventListener('click', (e) => {
771
- if (preview) e.preventDefault();
772
- else engaged('click');
784
+ if (preview) return e.preventDefault();
785
+ engaged('click');
786
+ leaving();
773
787
  });
774
788
  return node;
775
789
  }
@@ -1184,54 +1198,76 @@ export function mountForm(o) {
1184
1198
  (i) => i.value,
1185
1199
  ),
1186
1200
  ].filter(Boolean);
1187
- if (values.length) state.answers[key] = values;
1188
- else delete state.answers[key];
1201
+ if (values.length) {
1202
+ state.answers[key] = values;
1203
+ state.answeredOn[key] = state.step.id;
1204
+ } else delete state.answers[key];
1189
1205
  }
1190
1206
  }
1191
1207
 
1192
- /** After the sign-up, an answer the gateway did not carry is the page's to report. */
1208
+ /**
1209
+ * An answer the gateway did not carry is the page's to report: after the sign-up, or, on a form
1210
+ * that collects nothing (a quiz), as each step's answers are given.
1211
+ */
1193
1212
  function stated() {
1194
- const answers = [];
1195
- for (const [question, values] of Object.entries(state.answers)) {
1196
- const marker = `${question}=${values.join(',')}`;
1197
- if (state.sent.has(marker)) continue;
1198
- state.sent.add(marker);
1199
- answers.push({ question, values });
1200
- }
1201
- const step = state.step.id;
1213
+ const fresh = Object.entries(state.answers)
1214
+ .map(([question, values]) => ({ question, values }))
1215
+ .filter((a) => !state.sent.has(marker(a)));
1216
+ // Each answer goes with the step it was given on (collect records it), maybe an earlier one.
1217
+ const stepOf = ({ question }) => state.answeredOn[question];
1202
1218
  // After the email step, the gateway relays them to that address's profile and records them.
1203
- if (state.signedUp && answers.length)
1204
- return void api.answers({
1205
- anonymousId: access.anonymousId(),
1206
- sessionId: access.sessionId() ?? undefined,
1207
- slot: decision.slot,
1208
- arm: decision.arm ?? undefined,
1209
- decision_id: decision.decision_id ?? undefined,
1210
- step,
1211
- ...state.signedUp,
1212
- answers,
1213
- context: pageContext(),
1214
- });
1215
- if (access.analytics())
1216
- for (const answer of answers)
1217
- client.preferencesStated({
1218
- ...answer,
1219
- source: 'onsite',
1219
+ if (state.signedUp) {
1220
+ for (const step of new Set(fresh.map(stepOf))) {
1221
+ const answers = fresh.filter((a) => stepOf(a) === step);
1222
+ for (const answer of answers) state.sent.add(marker(answer));
1223
+ api.answers({
1224
+ anonymousId: access.anonymousId(),
1225
+ sessionId: access.sessionId() ?? undefined,
1220
1226
  slot: decision.slot,
1221
- arm: decision.arm ?? null,
1222
- decision_id: decision.decision_id ?? null,
1227
+ arm: decision.arm ?? undefined,
1228
+ decision_id: decision.decision_id ?? undefined,
1223
1229
  step,
1230
+ ...state.signedUp,
1231
+ answers,
1232
+ context: pageContext(),
1224
1233
  });
1234
+ }
1235
+ return;
1236
+ }
1237
+ // Kept back without analytics consent, so a later grant still reports it.
1238
+ if (!access.analytics()) return;
1239
+ for (const answer of fresh) {
1240
+ state.sent.add(marker(answer));
1241
+ client.preferencesStated({
1242
+ ...answer,
1243
+ source: 'onsite',
1244
+ slot: decision.slot,
1245
+ arm: decision.arm ?? null,
1246
+ decision_id: decision.decision_id ?? null,
1247
+ step: stepOf(answer),
1248
+ });
1249
+ }
1225
1250
  }
1226
1251
 
1252
+ const marker = ({ question, values }) => `${question}=${values.join(',')}`;
1253
+
1254
+ /** A quiz left by a link or closed keeps the answers given so far, as on moving on. */
1255
+ const leaving = () => {
1256
+ if (!asksNothing) return;
1257
+ collect();
1258
+ stated();
1259
+ };
1260
+
1227
1261
  const pageContext = () => ({
1228
1262
  page: { url: access.pageUrl() },
1229
1263
  locale: access.locale() ?? undefined,
1230
1264
  consent: access.consent(),
1231
1265
  });
1232
1266
 
1267
+ // Answers are reported as the form moves on: after a receipt, or on a form that collects nothing.
1268
+ // A sign-up form's answers before the sign-up travel with it.
1233
1269
  const advance = (explicit) => {
1234
- if (state.receipt && !preview) stated();
1270
+ if (!preview && (state.receipt || asksNothing)) stated();
1235
1271
  show(nextStep(state.step, state.answers, explicit));
1236
1272
  };
1237
1273
 
@@ -1475,14 +1511,19 @@ export function mountForm(o) {
1475
1511
  // A preview only moves between steps: nothing is checked, sent or claimed.
1476
1512
  if (preview) return advance(block.next);
1477
1513
  if (wrong) return say(wrong.error, wrong.input);
1514
+ // A quiz learns from clicks: moving on with an answer is a click.
1515
+ if (
1516
+ asksNothing &&
1517
+ state.step.blocks.some((b) => state.answers[b?.question])
1518
+ )
1519
+ engaged('click');
1478
1520
  if (block.action === 'apply') {
1479
1521
  o.emit?.('apply', {
1480
1522
  coupon: state.claim?.code ? { code: state.claim.code } : null,
1481
1523
  });
1482
1524
  engaged('click');
1483
- return nextStep(state.step, state.answers, block.next)
1484
- ? advance(block.next)
1485
- : close('done');
1525
+ // With no step after it, the form closes, done.
1526
+ return advance(block.next);
1486
1527
  }
1487
1528
  if (block.action === 'submit') return submit(block);
1488
1529
  if (block.action === 'spin') return spin(block);
@@ -1520,6 +1561,7 @@ export function mountForm(o) {
1520
1561
  function close(reason) {
1521
1562
  // A preview stays on screen: closing is the shopper's, not the trader's.
1522
1563
  if (state.closed || preview) return;
1564
+ leaving();
1523
1565
  if (!state.submitted && reason !== 'done') engaged('dismiss');
1524
1566
  counters.closed(form.slot);
1525
1567
  counters.tab?.(form.slot, false);
@@ -7,7 +7,11 @@
7
7
  * limited and the visitor allowed personalisation.
8
8
  */
9
9
 
10
- import { catalogueBrowse, sessionRulesHold } from '../session/rules.js';
10
+ import {
11
+ answeredRulesHold,
12
+ catalogueBrowse,
13
+ sessionRulesHold,
14
+ } from '../session/rules.js';
11
15
 
12
16
  /** What the decide request accepts: more, or a longer slug, and it ignores the lot. */
13
17
  const MAX_AUDIENCES = 100;
@@ -45,7 +49,9 @@ export function prefetch(api) {
45
49
  * personalisation consent.
46
50
  * @param {string[]} [targets] The audiences the form is limited to.
47
51
  * @param {string[]} [given] The session audiences the page gave (`startOnsite`'s `audiences()`).
48
- * @returns {Promise<string[]>}
52
+ * @returns {Promise<{ claims: string[], answered: string[] }>} `claims`, the slugs to send as
53
+ * `visit_audiences`; `answered`, the early audiences that hold on the answers this device keeps,
54
+ * as `answered_audiences`.
49
55
  */
50
56
  export async function visitAudiences(api, signals, targets = [], given = []) {
51
57
  const datafile = await prefetch(api);
@@ -54,7 +60,12 @@ export async function visitAudiences(api, signals, targets = [], given = []) {
54
60
  ? datafile.session_audiences
55
61
  : [];
56
62
  const held = datafile && visit ? holdingAudiences(datafile, visit) : [];
57
- return firstWanted([...held, ...given], targets, session);
63
+ const claims = firstWanted([...held, ...given], targets, session);
64
+ const answered =
65
+ datafile && visit
66
+ ? holdingAudiences(datafile, visit, answeredRulesHold)
67
+ : [];
68
+ return { claims, answered: firstWanted(answered, targets, session) };
58
69
  }
59
70
 
60
71
  /** `slugs`, once each, `targets` and their transitive `requires` first, capped. */
@@ -79,16 +90,15 @@ export function firstWanted(slugs, targets, session) {
79
90
  /**
80
91
  * @param {{ session_audiences?: unknown, browse_categories?: unknown }} datafile `GET /v1/audiences/datafile`.
81
92
  * @param {object} signals This visit's session signals.
93
+ * @param {(audience: object, signals: object) => boolean} [holds]
82
94
  * @returns {string[]}
83
95
  */
84
- export function holdingAudiences(datafile, signals) {
96
+ export function holdingAudiences(datafile, signals, holds = sessionRulesHold) {
85
97
  const audiences = Array.isArray(datafile?.session_audiences)
86
98
  ? datafile.session_audiences
87
99
  : [];
88
100
  const visit = catalogueBrowse(signals, datafile.browse_categories ?? null);
89
101
  return audiences
90
- .filter(
91
- (audience) => isSlug(audience?.slug) && sessionRulesHold(audience, visit),
92
- )
102
+ .filter((audience) => isSlug(audience?.slug) && holds(audience, visit))
93
103
  .map((audience) => audience.slug);
94
104
  }
@@ -33,6 +33,16 @@ const STRING_OPERATORS = {
33
33
  contains: (a, b) => text(b) && tagMatches(a, 'contains', b),
34
34
  };
35
35
 
36
+ /**
37
+ * Operators on an answer (`answer.<question>`): the values the shopper chose against the rule's
38
+ * list, as the SQL compiler reads them. With no answer kept, "is none of" holds, as `not exists`.
39
+ */
40
+ const LIST_OPERATORS = {
41
+ in: (chosen, list) => list.some((v) => chosen.includes(v)),
42
+ not_in: (chosen, list) => !list.some((v) => chosen.includes(v)),
43
+ all: (chosen, list) => list.every((v) => chosen.includes(v)),
44
+ };
45
+
36
46
  /**
37
47
  * Every operator this evaluator can apply, including `between`. A contract test keeps the session
38
48
  * trait catalogue inside this set, since an unsupported operator fails closed silently.
@@ -40,6 +50,7 @@ const STRING_OPERATORS = {
40
50
  export const SESSION_OPERATORS = new Set([
41
51
  ...Object.keys(OPERATORS),
42
52
  ...Object.keys(STRING_OPERATORS),
53
+ ...Object.keys(LIST_OPERATORS),
43
54
  'between',
44
55
  ]);
45
56
 
@@ -71,10 +82,11 @@ const MEASURES = {
71
82
  /**
72
83
  * What a rule's field is worth right now, or null if this build does not know the field.
73
84
  *
74
- * A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`), or one of the
85
+ * A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`), one of the
75
86
  * 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.
87
+ * untagged, or an answer (`answer.shopping_for`), the values chosen and empty for none, but null
88
+ * where the signals carry no answers (the onsite runtime's do). Exported so a storefront can
89
+ * explain a personalisation. Unknown fields fail the rule closed.
78
90
  */
79
91
  export function measureField(field, signals) {
80
92
  const separator = field.indexOf('.');
@@ -87,6 +99,13 @@ export function measureField(field, signals) {
87
99
  : null;
88
100
  return typeof tag === 'string' ? tag : null;
89
101
  }
102
+ if (name === 'answer') {
103
+ const { answers } = signals;
104
+ if (!answers || typeof answers !== 'object') return null;
105
+ return Object.hasOwn(answers, arg) && Array.isArray(answers[arg])
106
+ ? answers[arg]
107
+ : [];
108
+ }
90
109
  const fn = MEASURES[name];
91
110
  return fn && arg ? fn(signals, arg) : null;
92
111
  }
@@ -97,6 +116,14 @@ export function measureField(field, signals) {
97
116
  export function evaluateSessionRule(rule, signals) {
98
117
  const actual = measureField(rule.field, signals);
99
118
  if (actual === null) return false;
119
+ if (Array.isArray(actual)) {
120
+ const list = Array.isArray(rule.value) && rule.value.length > 0;
121
+ return (
122
+ list &&
123
+ Object.hasOwn(LIST_OPERATORS, rule.operator) &&
124
+ LIST_OPERATORS[rule.operator](actual, rule.value)
125
+ );
126
+ }
100
127
  if (typeof actual === 'string') {
101
128
  const compare = STRING_OPERATORS[rule.operator];
102
129
  return compare ? compare(actual, rule.value) : false;
@@ -146,6 +173,25 @@ export function sessionRulesHold(audience, signals) {
146
173
  : groupResults.every(Boolean);
147
174
  }
148
175
 
176
+ /**
177
+ * Whether an early audience (ADR 0064) holds on the answers this device keeps: its visit rules and
178
+ * its `answer_rules` together, grouped as authored. Never when the signals carry no answers.
179
+ */
180
+ export function answeredRulesHold(audience, signals) {
181
+ return (
182
+ audience.early === true &&
183
+ Array.isArray(audience.answer_rules) &&
184
+ typeof signals?.answers === 'object' &&
185
+ sessionRulesHold(
186
+ {
187
+ ...audience,
188
+ rules: [...(audience.rules ?? []), ...audience.answer_rules],
189
+ },
190
+ signals,
191
+ )
192
+ );
193
+ }
194
+
149
195
  /**
150
196
  * The audiences this visitor is in right now: precomputed membership from the edge, plus any
151
197
  * session audience whose rules hold.
@@ -229,12 +275,21 @@ export function resolveAudienceMemberships(
229
275
  if (active.has(audience.slug)) continue;
230
276
  const required = audience.requires ?? [];
231
277
  if (!required.every(met)) continue;
232
- if (audience.mixed && !qualified.has(audience.slug)) continue;
233
- if (!audience.rules || audience.rules.length === 0) continue;
234
- if (sessionRulesHold(audience, visit)) {
278
+ // An early audience is in from the answers this device keeps, without the warehouse: they are
279
+ // the shopper's own. Otherwise it is the MIXED audience it ships as.
280
+ const source = answeredRulesHold(audience, visit)
281
+ ? 'session'
282
+ : (!audience.mixed || qualified.has(audience.slug)) &&
283
+ audience.rules?.length > 0 &&
284
+ sessionRulesHold(audience, visit)
285
+ ? audience.mixed
286
+ ? 'mixed'
287
+ : 'session'
288
+ : null;
289
+ if (source) {
235
290
  active.add(audience.slug);
236
291
  // Mixed is its own source: half of it was decided by a batch run, not by this visit.
237
- bySlug.set(audience.slug, audience.mixed ? 'mixed' : 'session');
292
+ bySlug.set(audience.slug, source);
238
293
  admitted = true;
239
294
  }
240
295
  }
@@ -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.21';
4
+ export const SPEC_VERSION = '1.22';
@@ -70,6 +70,12 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
70
70
  * person id (3.26).
71
71
  */
72
72
  visitCampaign: 'thg_hp_visit_campaign',
73
+ /**
74
+ * localStorage, per property: the answers given to its onsite forms, each kept 90 days, so an
75
+ * audience on answers is decided as the shopper answers. Only under personalisation consent;
76
+ * question keys and option values, never contact data (3.31).
77
+ */
78
+ answers: 'thg_hp_answers',
73
79
  });
74
80
 
75
81
  /**
@@ -138,6 +144,22 @@ export function storagePrefix(name, tenant) {
138
144
  return `${name}:${segment(tenant)}:`;
139
145
  }
140
146
 
147
+ /**
148
+ * Remove every tenant's localStorage keys of one kind on this origin: consent is withdrawn on the
149
+ * device, and a sign-out ends what the device kept for everyone.
150
+ *
151
+ * @param {keyof typeof DEFAULT_STORAGE_NAMES} kind
152
+ */
153
+ export function forgetEveryScope(kind) {
154
+ const prefix = `${names[kind]}:`;
155
+ try {
156
+ for (const key of Object.keys(localStorage))
157
+ if (key.startsWith(prefix)) localStorage.removeItem(key);
158
+ } catch {
159
+ // Nothing was kept.
160
+ }
161
+ }
162
+
141
163
  /**
142
164
  * Move every legacy `cdp*` key to its current name, once per page, in both stores.
143
165
  *
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.29.0';
1
+ export const VERSION = '3.31.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { SESSION_TIMEOUT_MS } from './session-id.js';
16
- import { storageKey, storageName } from './storage-scope.js';
16
+ import { forgetEveryScope, storageKey, storageName } from './storage-scope.js';
17
17
 
18
18
  /** The longest a tag is read: the control plane's CAMPAIGN_VALUE_MAX, which the server cuts to. */
19
19
  const TAG_MAX = 100;
@@ -167,11 +167,5 @@ export function keepVisitCampaign(
167
167
  /** Forget every property's visit campaign on this origin: a sign-out, or consent withdrawn. */
168
168
  export function clearVisitCampaign() {
169
169
  memory.clear();
170
- try {
171
- const prefix = `${storageName('visitCampaign')}:`;
172
- for (const key of Object.keys(localStorage))
173
- if (key.startsWith(prefix)) localStorage.removeItem(key);
174
- } catch {
175
- /* nothing was kept */
176
- }
170
+ forgetEveryScope('visitCampaign');
177
171
  }
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.21
4
+ // Spec version: 1.22
5
5
 
6
6
  /** context.source.environment. Environment the producer runs in. */
7
7
  export type CdpSourceEnvironment = 'production' | 'staging' | 'development';
@@ -104,6 +104,7 @@ export interface RecommendationServed {
104
104
  strategy?: string | null;
105
105
  algorithm_version?: string | null;
106
106
  candidate_algorithm_version?: string | null;
107
+ shadow_algorithm_version?: string | null;
107
108
  ranked_by?: 'ranker' | 'blend' | 'session' | 'manual' | 'other' | null;
108
109
  fusion?: string | null;
109
110
  fast_weight?: number | null;
@@ -713,7 +714,7 @@ export interface SignUpRelayed {
713
714
  }
714
715
 
715
716
  /** The spec version these models implement. SDKs stamp it on context.spec.version. */
716
- export const SPEC_VERSION = '1.21';
717
+ export const SPEC_VERSION = '1.22';
717
718
 
718
719
  /** Every event name the registry accepts. Anything else is rejected at the ingest. */
719
720
  export type CdpEventName =
package/types/index.d.ts CHANGED
@@ -496,6 +496,11 @@ export interface StorageNames {
496
496
  preview?: string;
497
497
  /** sessionStorage: the debug token from `?hp_debug=` (3.18). Default 'thg_hp_debug'. */
498
498
  debug?: string;
499
+ /**
500
+ * localStorage, per property, under personalisation consent: the answers given to its onsite
501
+ * forms, each kept 90 days (3.31). Default 'thg_hp_answers'.
502
+ */
503
+ answers?: string;
499
504
  }
500
505
 
501
506
  /** Where a source's device ids come from: the settings' `hp.anonymousId` (since 3.6). */