@thg-agentic/hyper-personalisation-web 3.31.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.
@@ -5,7 +5,18 @@
5
5
  * RichText = string | Array<string | { b: RichText } | { i: RichText } | { a: RichText, href }>
6
6
  */
7
7
 
8
- const MAX_DEPTH = 6;
8
+ /** How deep marks nest before the rest is dropped. */
9
+ export const MAX_DEPTH = 6;
10
+
11
+ /** Whether a rich text holds a shopper value node (one with a `v`), to the depth it is drawn. */
12
+ export const holdsValue = (text, depth = 0) =>
13
+ depth <= MAX_DEPTH &&
14
+ typeof text === 'object' &&
15
+ !!text &&
16
+ (Array.isArray(text)
17
+ ? text.some((run) => holdsValue(run, depth + 1))
18
+ : typeof text.v === 'string' ||
19
+ holdsValue(text.b ?? text.i ?? text.a, depth + 1));
9
20
 
10
21
  /** The href as an https URL, or null. */
11
22
  export function httpsHref(href) {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Shopper values in a form's text, only for a form whose text has one: a bundler splits this import
3
+ * out of ./onsite, and the CDN build swaps this module for cdn/values-loader.js, which loads
4
+ * dist/onsite-values.min.js.
5
+ */
6
+ export const loadValues = () => import('./values.js');
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Shopper values in a form's text (ADR 0064 as amended 2026-11-03): a heading's or a paragraph's
3
+ * value nodes, `{ v, fallback, format? }`, filled in the browser from what this page already
4
+ * knows: the basket in this visit, the name the page gave (`hp.onsite.values`), and the shopper's
5
+ * own answers. Each is drawn as text, never markup; a value this page does not know shows its
6
+ * fallback, or with the block's `missing: "hide"` the block is not shown. Its own chunk, loaded
7
+ * only for a form whose text has a value.
8
+ */
9
+
10
+ import { money } from './money.js';
11
+ import { holdsValue, MAX_DEPTH } from './rich-text.js';
12
+
13
+ const VALUES = Symbol.for(
14
+ '@thg-agentic/hyper-personalisation-web.onsite.page-values',
15
+ );
16
+ /** The longest a value shows, its ellipsis included. */
17
+ const MAX = 60;
18
+ // Built from a string so the bundle stays ASCII whatever charset a CDN serves it with: esbuild
19
+ // writes a literal's characters as they are.
20
+ // biome-ignore lint/complexity/useRegexLiterals: a literal is bundled as non-ASCII characters.
21
+ const UNSEEN = new RegExp(
22
+ '[\\u0000-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u206f\\ufeff]',
23
+ 'g',
24
+ );
25
+ /**
26
+ * How a value shows when its node names no format: the first of its catalogue `formats` and its
27
+ * `plural` (spec/onsite/values.json; a test holds the two the same). Text otherwise.
28
+ */
29
+ export const DEFAULTS = {
30
+ basket_value: { style: 'money' },
31
+ basket_items: { style: 'number', plural: { one: 'item', other: 'items' } },
32
+ };
33
+ /** The prefix of an answer's key (the catalogue's `answers.prefix`). */
34
+ export const ANSWER_PREFIX = 'answer.';
35
+ /** The values only the page knows, given to `hp.onsite.values` or `startOnsite`. */
36
+ export const PAGE = ['first_name'];
37
+ const UNITS = [
38
+ ['year', 31536e6],
39
+ ['month', 2592e6],
40
+ ['week', 6048e5],
41
+ ['day', 864e5],
42
+ ['hour', 36e5],
43
+ ['minute', 6e4],
44
+ ];
45
+
46
+ /** A value as the text it shows: one line, without controls, at most MAX characters. */
47
+ export function clean(value) {
48
+ const chars = [
49
+ ...String(value).replace(UNSEEN, '').replace(/\s+/g, ' ').trim(),
50
+ ];
51
+ return chars.length > MAX
52
+ ? `${chars
53
+ .slice(0, MAX - 1)
54
+ .join('')
55
+ .trimEnd()}…`
56
+ : chars.join('');
57
+ }
58
+
59
+ /** `lang` when the browser takes it as a locale, else the browser's own. */
60
+ function localeOf(lang) {
61
+ try {
62
+ return Intl.getCanonicalLocales(lang || [])[0];
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ /** The `Intl` formatters made so far, by kind, language and options: one each for a page. */
69
+ const formatters = new Map();
70
+ const formatter = (key, make) => {
71
+ if (!formatters.has(key)) formatters.set(key, make());
72
+ return formatters.get(key);
73
+ };
74
+
75
+ /**
76
+ * `raw` as text in `style`: a number, money in `currency`, a date, a time from now, or text; with
77
+ * `plural` (`{ one, other }`) a number is followed by the word for it. Null when it cannot be.
78
+ */
79
+ export function formatValue(raw, style, { lang, currency, plural, now } = {}) {
80
+ if (raw === null || raw === undefined || raw === '') return null;
81
+ try {
82
+ if (style === 'date' || style === 'relative') {
83
+ const at = typeof raw === 'number' ? raw : Date.parse(raw);
84
+ if (!Number.isFinite(at)) return null;
85
+ if (style === 'date')
86
+ return formatter(
87
+ `date ${lang}`,
88
+ () => new Intl.DateTimeFormat(lang, { dateStyle: 'medium' }),
89
+ ).format(at);
90
+ const gap = at - (now ?? Date.now());
91
+ const [unit, ms] = UNITS.find(([, ms]) => Math.abs(gap) >= ms) ?? [
92
+ 'minute',
93
+ 6e4,
94
+ ];
95
+ return formatter(
96
+ `relative ${lang}`,
97
+ () => new Intl.RelativeTimeFormat(lang, { numeric: 'auto' }),
98
+ ).format(Math.round(gap / ms), unit);
99
+ }
100
+ if (style !== 'number' && style !== 'money') return clean(raw) || null;
101
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return null;
102
+ const numbers =
103
+ style === 'money'
104
+ ? formatter(`money ${lang} ${currency}`, () => money(lang, currency))
105
+ : formatter(`number ${lang}`, () => new Intl.NumberFormat(lang));
106
+ // An unknown currency: the value is not known, so its fallback shows.
107
+ if (!numbers) return null;
108
+ const text = numbers.format(raw);
109
+ if (!plural) return text;
110
+ const one =
111
+ formatter(`plural ${lang}`, () => new Intl.PluralRules(lang)).select(
112
+ raw,
113
+ ) === 'one';
114
+ return clean(`${text} ${(one ? plural.one : plural.other) ?? ''}`);
115
+ } catch {
116
+ // An unknown locale: the value is not known.
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The fill the renderer runs on each block it draws: a heading or paragraph with its values as
123
+ * text, or `false` when one is not known and the block says `missing: "hide"`; any other block,
124
+ * and one without values, as it was. `access.values()` is what the client knows of this visit;
125
+ * `given` the page's own values from `startOnsite`, which `hp.onsite.values` replaces.
126
+ *
127
+ * @param {{ locale?: string, questions?: Array<{ key: string, options?: Array<{ value: string, label: string }> }> }} form
128
+ * @param {{ values?: () => { basket_value?: number|null, basket_items?: number|null, currency?: string|null, answers?: Record<string, string[]>|null } }} access
129
+ * @param {Record<string, unknown>} [given]
130
+ */
131
+ export function fillFor(form, access, given) {
132
+ const lang = localeOf(
133
+ form.locale || globalThis.document?.documentElement.lang,
134
+ );
135
+ const questions = new Map(
136
+ (Array.isArray(form.questions) ? form.questions : []).map((q) => [
137
+ q?.key,
138
+ q,
139
+ ]),
140
+ );
141
+ // What the page knows, read once for the blocks of a step: they are filled together.
142
+ let known = null;
143
+ const read = () => {
144
+ if (!known) {
145
+ const page = globalThis.window?.[VALUES] ?? given ?? {};
146
+ known = { ...access.values?.() };
147
+ for (const key of PAGE)
148
+ if (typeof page[key] === 'string') known[key] = page[key];
149
+ queueMicrotask(() => {
150
+ known = null;
151
+ });
152
+ }
153
+ return known;
154
+ };
155
+
156
+ /** A choice question's answer as its options' labels: this form's, else the ones kept. */
157
+ const answer = (key, asked) => {
158
+ const q = questions.get(key);
159
+ const { answers } = read();
160
+ // Answers are shown back only where they may be kept: under personalisation consent.
161
+ if (!q || !answers) return null;
162
+ const chosen = asked?.[key] ?? answers[key];
163
+ const labels = (Array.isArray(chosen) ? chosen : []).flatMap((value) => {
164
+ const label = q.options?.find((o) => o?.value === value)?.label;
165
+ return typeof label === 'string' && label ? [clean(label)] : [];
166
+ });
167
+ if (!labels.length) return null;
168
+ try {
169
+ return formatter(
170
+ `list ${lang}`,
171
+ () => new Intl.ListFormat(lang, { type: 'conjunction' }),
172
+ ).format(labels);
173
+ } catch {
174
+ // A browser without Intl.ListFormat (Safari before 14.1).
175
+ return labels.join(', ');
176
+ }
177
+ };
178
+
179
+ const resolve = (node, asked) => {
180
+ const key = node.v;
181
+ const set =
182
+ node.format && typeof node.format === 'object'
183
+ ? node.format
184
+ : DEFAULTS[key];
185
+ const raw = key.startsWith(ANSWER_PREFIX)
186
+ ? answer(key.slice(ANSWER_PREFIX.length), asked)
187
+ : read()[key];
188
+ return formatValue(raw, set?.style ?? 'text', {
189
+ lang,
190
+ currency: read().currency,
191
+ plural: set?.plural,
192
+ });
193
+ };
194
+
195
+ return (block, asked) => {
196
+ if (
197
+ (block?.t !== 'heading' && block?.t !== 'text') ||
198
+ !holdsValue(block.text)
199
+ )
200
+ return block;
201
+ let missed = false;
202
+ const walk = (text, depth) => {
203
+ if (typeof text === 'string' || depth > MAX_DEPTH) return text;
204
+ if (Array.isArray(text)) return text.map((t) => walk(t, depth + 1));
205
+ if (!text || typeof text !== 'object') return text;
206
+ if (typeof text.v === 'string') {
207
+ const value = resolve(text, asked);
208
+ if (value === null) missed = true;
209
+ return value ?? clean(text.fallback ?? '');
210
+ }
211
+ const tag =
212
+ 'b' in text ? 'b' : 'i' in text ? 'i' : 'a' in text ? 'a' : null;
213
+ return tag ? { ...text, [tag]: walk(text[tag], depth + 1) } : text;
214
+ };
215
+ const text = walk(block.text, 0);
216
+ return missed && block.missing === 'hide' ? false : { ...block, text };
217
+ };
218
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * `hp.onsite`: open (`show`) a form by hand, listen to the forms, keep the screen clear, or read why
3
- * each form did or did not show, all callable before the onsite runtime has loaded. Listeners and
2
+ * `hp.onsite`: open (`show`) a form by hand, listen to the forms, keep the screen clear, give a form's
3
+ * text what the page knows of the shopper, or read why each form did or did not show, all callable
4
+ * before the onsite runtime has loaded. Listeners and
4
5
  * suppressions wait on the page for the runtime. An `open` made before it started waits for it, and resolves with what
5
6
  * the runtime did, or `false` once the page knows no runtime will start; nothing here throws.
6
7
  */
@@ -15,6 +16,9 @@ const LISTENERS = Symbol.for(
15
16
  const SUPPRESSED = Symbol.for(
16
17
  '@thg-agentic/hyper-personalisation-web.onsite.suppressed',
17
18
  );
19
+ const PAGE_VALUES = Symbol.for(
20
+ '@thg-agentic/hyper-personalisation-web.onsite.page-values',
21
+ );
18
22
  const EVENTS = ['shown', 'submitted', 'apply', 'closed', 'held', 'nothing'];
19
23
  const MAX_PENDING = 10;
20
24
 
@@ -93,6 +97,20 @@ export const onsite = Object.freeze({
93
97
  /* never into the page */
94
98
  }
95
99
  },
100
+ /**
101
+ * What the page knows of the shopper for a form's text: `{ first_name }`. Kept in this page's
102
+ * memory only and drawn as text in a form that shows it; never sent, stored or logged. Each call
103
+ * replaces the last, and `values({})` forgets it (a sign-out).
104
+ */
105
+ values(given) {
106
+ try {
107
+ const w = page();
108
+ if (w)
109
+ w[PAGE_VALUES] = given && typeof given === 'object' ? { ...given } : {};
110
+ } catch {
111
+ /* never into the page */
112
+ }
113
+ },
96
114
  /**
97
115
  * Each form's state, as `debug`, once the page knows whether onsite forms run on it: when the
98
116
  * runtime has started, or `[]` once none will (not opted in, no live form, the chunk failed).
@@ -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
@@ -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`.
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.31.0';
1
+ export const VERSION = '3.32.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -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[];