@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.
- package/README.md +71 -11
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +2 -2
- package/dist/onsite-products.min.js +2 -2
- package/dist/onsite-render.min.js +2 -2
- package/dist/onsite-values.min.js +1 -0
- package/dist/onsite-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +3 -2
- package/src/index.js +38 -1
- package/src/onsite/answers.js +6 -3
- package/src/onsite/api.js +5 -2
- package/src/onsite/countdown.js +21 -11
- package/src/onsite/index.js +100 -23
- package/src/onsite/money.js +11 -0
- package/src/onsite/page-facts.js +61 -0
- package/src/onsite/preview.js +27 -12
- package/src/onsite/products.js +3 -9
- package/src/onsite/render.js +14 -9
- package/src/onsite/rich-text.js +12 -1
- package/src/onsite/rules.js +47 -8
- package/src/onsite/values-loader.js +6 -0
- package/src/onsite/values.js +218 -0
- package/src/onsite-facade.js +57 -2
- package/src/session/record.js +18 -0
- package/src/session/signals.js +4 -0
- package/src/version.js +1 -1
- package/types/onsite-preview.d.ts +40 -0
- package/types/onsite.d.ts +73 -3
|
@@ -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
|
+
}
|
package/src/onsite-facade.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hp.onsite`: open (`show`) a form by hand, listen to the forms, keep the screen clear,
|
|
3
|
-
* each form did or did not show, all callable
|
|
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).
|
package/src/session/record.js
CHANGED
|
@@ -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
|
package/src/session/signals.js
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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[];
|