@thg-agentic/hyper-personalisation-web 3.30.0 → 3.32.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.
@@ -1,3 +1,4 @@
1
+ import { currencyCode } from '../context.js';
1
2
  import { bump, categoryKey, pathPrefix } from './category.js';
2
3
  import { readSignals, writeSignals } from './signals.js';
3
4
 
@@ -64,6 +65,18 @@ const basketOf = (products) =>
64
65
  ),
65
66
  ].slice(0, RECENT_LIMIT);
66
67
 
68
+ /**
69
+ * How many items a cart event's lines hold: a whole positive quantity as it is, a line without one
70
+ * or with a fractional one (a weight) once, and one of zero, below or not a number not at all.
71
+ */
72
+ export const basketItemsOf = (products) =>
73
+ products.reduce((n, p) => {
74
+ const q = p?.quantity;
75
+ if (q === undefined || q === null) return n + 1;
76
+ if (typeof q !== 'number' || !Number.isFinite(q) || q <= 0) return n;
77
+ return n + (Number.isInteger(q) ? q : 1);
78
+ }, 0);
79
+
67
80
  /**
68
81
  * Fold one CDP event into the session signals.
69
82
  *
@@ -192,6 +205,11 @@ export function recordSessionEvent(eventName, properties, campaign) {
192
205
  // Its lines are the basket as it now stands, merges and removals included.
193
206
  if (Array.isArray(properties?.products))
194
207
  s.basketProducts = basketOf(products);
208
+ s.basketItems = Array.isArray(properties?.products)
209
+ ? basketItemsOf(products)
210
+ : null;
211
+ // The basket's own currency, so its value is never shown in another.
212
+ s.basketCurrency = currencyCode(properties?.currency);
195
213
  const value = properties?.value;
196
214
  s.basketValue =
197
215
  typeof value === 'number' && Number.isFinite(value) && value >= 0
@@ -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
  }
@@ -91,6 +91,10 @@ function emptySignals() {
91
91
  // The last Cart Updated's `value`, in its currency. Unknown (so every rule on it fails closed)
92
92
  // until the visit sends one: a basket carried over from an earlier visit is not zero.
93
93
  basketValue: null,
94
+ // How many items the last Cart Updated's lines hold; unknown until one lists them.
95
+ basketItems: null,
96
+ // The last Cart Updated's `currency`, the one its value is in; unknown when it named none.
97
+ basketCurrency: null,
94
98
  recentSearches: [],
95
99
  // Shown-and-ignored counts, kept apart from `recentProducts` so a rail's own impressions never
96
100
  // read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
@@ -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.30.0';
1
+ export const VERSION = '3.32.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/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). */
@@ -34,6 +34,17 @@ export interface PreviewOptions {
34
34
  prizes?: Array<{ key: string; label: string }>;
35
35
  /** The basket a progress block is drawn against; 32 when left out. */
36
36
  basketValue?: number;
37
+ /**
38
+ * A returning shopper's values for the text's shopper values (`answers` by question, as option
39
+ * values); null or left out, every value shows its fallback, as a new visitor sees it.
40
+ */
41
+ values?: {
42
+ first_name?: string;
43
+ basket_value?: number;
44
+ basket_items?: number;
45
+ currency?: string;
46
+ answers?: Record<string, string[]>;
47
+ } | null;
37
48
  }
38
49
 
39
50
  export interface OnsitePreview {
@@ -56,3 +67,19 @@ export declare function mountPreview(
56
67
 
57
68
  /** The countries a phone field's country code picker offers, by ISO code, with their calling codes. */
58
69
  export declare const CALLING_CODES: Readonly<Record<string, string>>;
70
+
71
+ /**
72
+ * A shopper value as the SDK draws it in a form's text: a number, money in `currency`, a date, a
73
+ * time from now or text, in `lang`; with `plural`, a number followed by its word. Null when it
74
+ * cannot be shown (an unknown currency, not a number).
75
+ */
76
+ export declare function formatValue(
77
+ raw: unknown,
78
+ style: 'number' | 'money' | 'date' | 'relative' | 'text',
79
+ options?: {
80
+ lang?: string;
81
+ currency?: string | null;
82
+ plural?: { one: string; other: string };
83
+ now?: number;
84
+ },
85
+ ): string | null;
package/types/onsite.d.ts CHANGED
@@ -1,12 +1,41 @@
1
1
  import type { AnalyticsClient } from './index';
2
2
 
3
- /** RichText: plain text, bold, italic and https links. No markup: anything else is dropped. */
3
+ /**
4
+ * RichText: plain text, bold, italic, https links and shopper values. No markup: anything else is
5
+ * dropped.
6
+ */
4
7
  export type RichText =
5
8
  | string
6
9
  | Array<
7
- string | { b: RichText } | { i: RichText } | { a: RichText; href: string }
10
+ | string
11
+ | { b: RichText }
12
+ | { i: RichText }
13
+ | { a: RichText; href: string }
14
+ | OnsiteValueNode
8
15
  >;
9
16
 
17
+ /**
18
+ * A shopper value in a heading's or a paragraph's text (3.32): filled in the browser as text, never
19
+ * markup, at most 60 characters. `v` is a key of spec/onsite/values.json: `basket_value`,
20
+ * `basket_items`, `first_name` (the page's, `hp.onsite.values`) or `answer.<question>` (the label
21
+ * of the shopper's answer, under personalisation consent). Unknown, it shows `fallback`, or with
22
+ * the block's `missing: "hide"` the block is not shown. Never inside a link.
23
+ */
24
+ export interface OnsiteValueNode {
25
+ v: string;
26
+ fallback: string;
27
+ format?: {
28
+ style: 'number' | 'money' | 'date' | 'relative' | 'text';
29
+ /** After a number, the word for its count: `{ one: 'item', other: 'items' }`. */
30
+ plural?: { one: string; other: string };
31
+ };
32
+ }
33
+
34
+ /** What the page knows of the shopper for a form's text. Never sent, stored or logged. */
35
+ export interface OnsitePageValues {
36
+ first_name?: string;
37
+ }
38
+
10
39
  export type OnsiteFormat =
11
40
  | 'popup'
12
41
  | 'sheet'
@@ -171,6 +200,8 @@ export interface OnsiteOptions {
171
200
  /** Log why each form did or did not show. Also on with `?hp_debug=onsite` or the SDK's debug. */
172
201
  debug?: boolean;
173
202
  fetch?: typeof fetch;
203
+ /** What the page knows of the shopper for a form's text; `hp.onsite.values` replaces it. */
204
+ values?: OnsitePageValues;
174
205
  }
175
206
 
176
207
  /**
@@ -208,6 +239,7 @@ export type OnsiteNothingCode =
208
239
  | 'no-code'
209
240
  | 'renderer-failed'
210
241
  | 'products-failed'
242
+ | 'values-failed'
211
243
  | 'error';
212
244
 
213
245
  /** One form's state on this page, and why. */
@@ -291,6 +323,11 @@ export interface OnsiteFacade {
291
323
  suppress(reason?: string): void;
292
324
  /** Lift `reason`, or every reason. */
293
325
  unsuppress(reason?: string): void;
326
+ /**
327
+ * What the page knows of the shopper for a form's text (3.32), `{ first_name }`: in this page's
328
+ * memory only, never sent, stored or logged. Each call replaces the last; `values({})` forgets.
329
+ */
330
+ values(given: OnsitePageValues): void;
294
331
  /** Each form's state once the page knows whether onsite forms run on it; `[]` when none will. */
295
332
  ready(): Promise<OnsiteTrace[]>;
296
333
  debug(): OnsiteTrace[];