@thg-agentic/hyper-personalisation-web 3.31.0 → 3.34.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.
@@ -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,11 +16,30 @@ 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
+ );
22
+ const PAGE_FACTS = Symbol.for(
23
+ '@thg-agentic/hyper-personalisation-web.onsite.page-facts',
24
+ );
25
+ /** The runtime's listeners for what the page says: its facts, or the product it is about. */
26
+ const PAGE_SAID = Symbol.for(
27
+ '@thg-agentic/hyper-personalisation-web.onsite.page-said',
28
+ );
18
29
  const EVENTS = ['shown', 'submitted', 'apply', 'closed', 'held', 'nothing'];
19
30
  const MAX_PENDING = 10;
20
31
 
21
32
  const page = () => (typeof window === 'undefined' ? null : window);
22
33
 
34
+ /** The page said what it is about (facts, or a Product Viewed): the runtime judges again what waited. */
35
+ export function pageSaid() {
36
+ try {
37
+ for (const heard of page()?.[PAGE_SAID] ?? []) heard();
38
+ } catch {
39
+ /* never into the page */
40
+ }
41
+ }
42
+
23
43
  /** No onsite runtime will start on this page: every `open` waiting for one resolves `false`. */
24
44
  export function dropPendingOpens() {
25
45
  try {
@@ -93,6 +113,41 @@ export const onsite = Object.freeze({
93
113
  /* never into the page */
94
114
  }
95
115
  },
116
+ /**
117
+ * What the page knows of the shopper for a form's text: `{ first_name }`. Kept in this page's
118
+ * memory only and drawn as text in a form that shows it; never sent, stored or logged. Each call
119
+ * replaces the last, and `values({})` forgets it (a sign-out).
120
+ */
121
+ values(given) {
122
+ try {
123
+ const w = page();
124
+ if (w)
125
+ w[PAGE_VALUES] = given && typeof given === 'object' ? { ...given } : {};
126
+ } catch {
127
+ /* never into the page */
128
+ }
129
+ },
130
+ /**
131
+ * What this page says about itself, for forms limited by page facts:
132
+ * `{ next_day_eligible: true }`, each value true or false, a number or words. Kept in memory for
133
+ * this page's path only (a single-page app's next page starts with none); a form's request sends
134
+ * only the facts its rules read, never logged.
135
+ * Each call replaces the last (call it again when the shopper picks another size), and
136
+ * `facts({})` forgets.
137
+ */
138
+ facts(given) {
139
+ try {
140
+ const w = page();
141
+ if (w)
142
+ w[PAGE_FACTS] = {
143
+ path: w.location?.pathname,
144
+ facts: given && typeof given === 'object' ? { ...given } : {},
145
+ };
146
+ } catch {
147
+ /* never into the page */
148
+ }
149
+ pageSaid();
150
+ },
96
151
  /**
97
152
  * Each form's state, as `debug`, once the page knows whether onsite forms run on it: when the
98
153
  * 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.34.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -34,6 +34,19 @@ 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
+ /** The form's daily cut-off as the settings carry it (`window.daily`), which a `daily` countdown counts to. */
38
+ daily?: DailyCutoff;
39
+ /**
40
+ * A returning shopper's values for the text's shopper values (`answers` by question, as option
41
+ * values); null or left out, every value shows its fallback, as a new visitor sees it.
42
+ */
43
+ values?: {
44
+ first_name?: string;
45
+ basket_value?: number;
46
+ basket_items?: number;
47
+ currency?: string;
48
+ answers?: Record<string, string[]>;
49
+ } | null;
37
50
  }
38
51
 
39
52
  export interface OnsitePreview {
@@ -56,3 +69,30 @@ export declare function mountPreview(
56
69
 
57
70
  /** The countries a phone field's country code picker offers, by ISO code, with their calling codes. */
58
71
  export declare const CALLING_CODES: Readonly<Record<string, string>>;
72
+
73
+ /**
74
+ * A shopper value as the SDK draws it in a form's text: a number, money in `currency`, a date, a
75
+ * time from now or text, in `lang`; with `plural`, a number followed by its word. Null when it
76
+ * cannot be shown (an unknown currency, not a number).
77
+ */
78
+ export declare function formatValue(
79
+ raw: unknown,
80
+ style: 'number' | 'money' | 'date' | 'relative' | 'text',
81
+ options?: {
82
+ lang?: string;
83
+ currency?: string | null;
84
+ plural?: { one: string; other: string };
85
+ now?: number;
86
+ },
87
+ ): string | null;
88
+
89
+ /**
90
+ * A form's daily cut-off as the settings carry it (`window.daily`): the moments it falls, which the
91
+ * server works out in the shop's time zone, and a `daily` countdown counts to the first ahead.
92
+ */
93
+ export interface DailyCutoff {
94
+ /** The cut-offs, epoch milliseconds, earliest first, up to 8 days on from when they were listed. */
95
+ next: readonly number[];
96
+ /** The form shows only while the next cut-off is at most this many hours away. */
97
+ within_hours?: number;
98
+ }
package/types/onsite.d.ts CHANGED
@@ -1,12 +1,51 @@
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 a page says about itself, for forms limited by page facts (3.34). */
35
+ export type OnsitePageFacts = Record<string, boolean | number | string>;
36
+
37
+ /** One rule on a page fact: `is` / `is_not` one of `values`, or `at_least` / `at_most` a number. */
38
+ export interface OnsitePageFactRule {
39
+ fact: string;
40
+ op: 'is' | 'is_not' | 'at_least' | 'at_most';
41
+ values: Array<boolean | number | string>;
42
+ }
43
+
44
+ /** What the page knows of the shopper for a form's text. Never sent, stored or logged. */
45
+ export interface OnsitePageValues {
46
+ first_name?: string;
47
+ }
48
+
10
49
  export type OnsiteFormat =
11
50
  | 'popup'
12
51
  | 'sheet'
@@ -56,11 +95,26 @@ export interface OnsiteForm {
56
95
  match: 'is' | 'contains';
57
96
  values: string[];
58
97
  }>;
98
+ /**
99
+ * Shown only where the page says so (3.34): rules on the facts it gave with `hp.onsite.facts`.
100
+ * Every rule must hold; a fact the page did not give meets none.
101
+ */
102
+ page_facts?: OnsitePageFactRule[];
103
+ /** Shown only on the pages of chosen products (3.34): the server checks the page's product. */
104
+ products?: true;
59
105
  /** Shown only with personalisation consent, and only to members as the server decides. */
60
106
  audiences?: string[];
61
107
  exclude_subscribed?: boolean;
62
108
  caps?: { per_session?: number; lifetime?: number; after_close_days?: number };
63
- window?: { starts_at?: string; ends_at?: string };
109
+ /**
110
+ * Its dates and its daily cut-off: with one, it shows only while one of its listed cut-offs is
111
+ * still ahead and, with `within_hours`, only that close to it (3.33).
112
+ */
113
+ window?: {
114
+ starts_at?: string;
115
+ ends_at?: string;
116
+ daily?: import('./onsite-preview').DailyCutoff;
117
+ };
64
118
  /**
65
119
  * The form's language, BCP 47 (`en-IE`): its phone field starts on this region's country code,
66
120
  * and a birthday's day and month follow its order and month names. Absent: the page's `lang`.
@@ -171,6 +225,8 @@ export interface OnsiteOptions {
171
225
  /** Log why each form did or did not show. Also on with `?hp_debug=onsite` or the SDK's debug. */
172
226
  debug?: boolean;
173
227
  fetch?: typeof fetch;
228
+ /** What the page knows of the shopper for a form's text; `hp.onsite.values` replaces it. */
229
+ values?: OnsitePageValues;
174
230
  }
175
231
 
176
232
  /**
@@ -208,6 +264,7 @@ export type OnsiteNothingCode =
208
264
  | 'no-code'
209
265
  | 'renderer-failed'
210
266
  | 'products-failed'
267
+ | 'values-failed'
211
268
  | 'error';
212
269
 
213
270
  /** One form's state on this page, and why. */
@@ -291,6 +348,19 @@ export interface OnsiteFacade {
291
348
  suppress(reason?: string): void;
292
349
  /** Lift `reason`, or every reason. */
293
350
  unsuppress(reason?: string): void;
351
+ /**
352
+ * What the page knows of the shopper for a form's text (3.32), `{ first_name }`: in this page's
353
+ * memory only, never sent, stored or logged. Each call replaces the last; `values({})` forgets.
354
+ */
355
+ values(given: OnsitePageValues): void;
356
+ /**
357
+ * What this page says about itself (3.34), such as `{ next_day_eligible: true }`: kept in memory
358
+ * for this page's path only; a form's decision request sends only the facts its rules read,
359
+ * never logged. Each call replaces the last; call it again when the shopper picks another size.
360
+ * `facts({})` forgets. Names are lower case, digits and underscores; values true or false, a
361
+ * number or words; at most 20.
362
+ */
363
+ facts(given: OnsitePageFacts): void;
294
364
  /** Each form's state once the page knows whether onsite forms run on it; `[]` when none will. */
295
365
  ready(): Promise<OnsiteTrace[]>;
296
366
  debug(): OnsiteTrace[];