@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.
- package/README.md +44 -5
- package/dist/hp.min.js +3 -3
- package/dist/onsite-audiences.min.js +1 -1
- 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.min.js +1 -1
- package/package.json +3 -2
- package/src/answer-memory.js +101 -0
- package/src/index.js +76 -5
- package/src/onsite/api.js +21 -4
- package/src/onsite/index.js +40 -22
- package/src/onsite/money.js +11 -0
- package/src/onsite/preview.js +24 -12
- package/src/onsite/products.js +3 -9
- package/src/onsite/render.js +9 -7
- package/src/onsite/rich-text.js +12 -1
- package/src/onsite/values-loader.js +6 -0
- package/src/onsite/values.js +218 -0
- package/src/onsite/visit-audiences.js +17 -7
- package/src/onsite-facade.js +20 -2
- package/src/session/record.js +18 -0
- package/src/session/rules.js +62 -7
- package/src/session/signals.js +4 -0
- package/src/storage-scope.js +22 -0
- package/src/version.js +1 -1
- package/src/visit-campaign.js +2 -8
- package/types/index.d.ts +5 -0
- package/types/onsite-preview.d.ts +27 -0
- package/types/onsite.d.ts +39 -2
package/src/onsite/api.js
CHANGED
|
@@ -61,6 +61,19 @@ export function createApi(access, options = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A sign-up or its later answers, to the gateway. Once it takes them (2xx), the answers are kept
|
|
66
|
+
* on the device under personalisation consent, as a quiz's are (ADR 0064).
|
|
67
|
+
*/
|
|
68
|
+
const taken = (path, body) =>
|
|
69
|
+
call(`${gateway}/v1/capture${path}`, { method: 'POST', body }).then(
|
|
70
|
+
(res) => {
|
|
71
|
+
if (res.status >= 200 && res.status < 300)
|
|
72
|
+
access.answered?.(body.answers, body.slot);
|
|
73
|
+
return res;
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
64
77
|
return {
|
|
65
78
|
/**
|
|
66
79
|
* The decision for one slot, as the page's other serving calls ask for it, with its pin and
|
|
@@ -100,7 +113,12 @@ export function createApi(access, options = {}) {
|
|
|
100
113
|
// What a promo reminder's code is answered for under a publishable key (ADR 0067).
|
|
101
114
|
identity_token: access.identityToken?.() ?? undefined,
|
|
102
115
|
...(access.preview ? { preview: access.preview } : {}),
|
|
103
|
-
...(visitAudiences
|
|
116
|
+
...(visitAudiences && {
|
|
117
|
+
visit_audiences: visitAudiences.claims,
|
|
118
|
+
...(visitAudiences.answered?.length && {
|
|
119
|
+
answered_audiences: visitAudiences.answered,
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
104
122
|
...(visitCampaign ? { visit_campaign: visitCampaign } : {}),
|
|
105
123
|
...(visitContext ? { visit_context: visitContext } : {}),
|
|
106
124
|
},
|
|
@@ -111,10 +129,9 @@ export function createApi(access, options = {}) {
|
|
|
111
129
|
timeoutMs: DATAFILE_TIMEOUT_MS,
|
|
112
130
|
}),
|
|
113
131
|
challenge: () => call(`${gateway}${challengePath(options.challenge)}`),
|
|
114
|
-
capture: (body) =>
|
|
132
|
+
capture: (body) => taken('', body),
|
|
115
133
|
// Answers given after the email step, with its address and receipt.
|
|
116
|
-
answers: (body) =>
|
|
117
|
-
call(`${gateway}/v1/capture/answers`, { method: 'POST', body }),
|
|
134
|
+
answers: (body) => taken('/answers', body),
|
|
118
135
|
// Bounded, so the page's `submitted`, which waits for the claim, always comes.
|
|
119
136
|
claim: (body) =>
|
|
120
137
|
call(`${serving}/v1/offers/claim`, {
|
package/src/onsite/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { loadAudiences } from './audiences-loader.js';
|
|
|
23
23
|
import { createCounters } from './counters.js';
|
|
24
24
|
import { loadProducts } from './products-loader.js';
|
|
25
25
|
import { loadRender } from './render-loader.js';
|
|
26
|
+
import { holdsValue } from './rich-text.js';
|
|
26
27
|
import {
|
|
27
28
|
ASKED,
|
|
28
29
|
blockers,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
readForm,
|
|
35
36
|
} from './rules.js';
|
|
36
37
|
import { watch } from './triggers.js';
|
|
38
|
+
import { loadValues } from './values-loader.js';
|
|
37
39
|
import { loadWheel } from './wheel-loader.js';
|
|
38
40
|
|
|
39
41
|
const ACCESS = Symbol.for(
|
|
@@ -75,6 +77,7 @@ const NOTHING = new Set([
|
|
|
75
77
|
'no-code',
|
|
76
78
|
'renderer-failed',
|
|
77
79
|
'products-failed',
|
|
80
|
+
'values-failed',
|
|
78
81
|
'error',
|
|
79
82
|
]);
|
|
80
83
|
|
|
@@ -102,14 +105,9 @@ const debugParam = () => {
|
|
|
102
105
|
}
|
|
103
106
|
};
|
|
104
107
|
|
|
105
|
-
/** Whether
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
creative.steps.some(
|
|
109
|
-
(step) =>
|
|
110
|
-
Array.isArray(step?.blocks) &&
|
|
111
|
-
step.blocks.some((block) => block?.t === 'products'),
|
|
112
|
-
);
|
|
108
|
+
/** Whether any block of any of the creative's steps meets `uses`. */
|
|
109
|
+
const stepsUse = (creative, uses) =>
|
|
110
|
+
creative.steps.some((step) => step.blocks.some(uses));
|
|
113
111
|
|
|
114
112
|
/**
|
|
115
113
|
* Start onsite forms for `client`, once per page. Returns the controller, or null without a
|
|
@@ -131,6 +129,7 @@ export function startOnsite(client, options = {}) {
|
|
|
131
129
|
return null;
|
|
132
130
|
}
|
|
133
131
|
const settings = options.settings ?? access.settings ?? {};
|
|
132
|
+
access.started?.(settings);
|
|
134
133
|
const api = createApi(access, {
|
|
135
134
|
...options,
|
|
136
135
|
challenge: settings.capture?.challenge,
|
|
@@ -414,9 +413,11 @@ export function startOnsite(client, options = {}) {
|
|
|
414
413
|
// The evaluator orders the page's own with the visit's (targets and what they require first);
|
|
415
414
|
// a fallback sends the page's alone, the form's targets first and capped at the server's 100.
|
|
416
415
|
const page = sessionAudiences() ?? [];
|
|
417
|
-
const given =
|
|
418
|
-
.
|
|
419
|
-
|
|
416
|
+
const given = {
|
|
417
|
+
claims: [...new Set([...form.audiences, ...page])]
|
|
418
|
+
.filter((slug) => page.includes(slug))
|
|
419
|
+
.slice(0, 100),
|
|
420
|
+
};
|
|
420
421
|
const claimed =
|
|
421
422
|
form.audiences.length > 0 && !access.preview && access.personalisation()
|
|
422
423
|
? await within(
|
|
@@ -518,20 +519,36 @@ export function startOnsite(client, options = {}) {
|
|
|
518
519
|
return drop(form, 'not shown', 'renderer-failed', [
|
|
519
520
|
'the renderer did not load',
|
|
520
521
|
]);
|
|
521
|
-
// Its products
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
]);
|
|
528
|
-
}
|
|
529
|
-
// Its first step's pictures fetched and decoded first, so it never opens on an empty one, and
|
|
530
|
-
// never waits for them longer than WARM_MS or past its deadline.
|
|
531
|
-
await within(
|
|
522
|
+
// Its products and its text's values are drawn by chunks of their own, fetched together while
|
|
523
|
+
// its first step's pictures warm: without them the form would open on an empty block, or on
|
|
524
|
+
// text with a gap. The pictures are waited for no longer than WARM_MS or past its deadline.
|
|
525
|
+
const products = stepsUse(creative, (b) => b?.t === 'products');
|
|
526
|
+
if (products) cards ??= loadProducts().catch(() => null);
|
|
527
|
+
const warming = within(
|
|
532
528
|
render.warm?.(form, creative, creative.steps[0].id, warmed),
|
|
533
529
|
Math.min(deadline, Date.now() + WARM_MS),
|
|
534
530
|
);
|
|
531
|
+
const [drawn, values] = await Promise.all([
|
|
532
|
+
!products || within(cards, deadline, null),
|
|
533
|
+
stepsUse(
|
|
534
|
+
creative,
|
|
535
|
+
(b) => (b?.t === 'heading' || b?.t === 'text') && holdsValue(b.text),
|
|
536
|
+
) &&
|
|
537
|
+
within(
|
|
538
|
+
loadValues().catch(() => null),
|
|
539
|
+
deadline,
|
|
540
|
+
null,
|
|
541
|
+
),
|
|
542
|
+
]);
|
|
543
|
+
if (!drawn)
|
|
544
|
+
return drop(form, 'not shown', 'products-failed', [
|
|
545
|
+
'its products did not load',
|
|
546
|
+
]);
|
|
547
|
+
if (values === null)
|
|
548
|
+
return drop(form, 'not shown', 'values-failed', [
|
|
549
|
+
'its values did not load',
|
|
550
|
+
]);
|
|
551
|
+
await warming;
|
|
535
552
|
if (suppressed())
|
|
536
553
|
return drop(form, 'not shown', 'suppressed', ['suppressed']);
|
|
537
554
|
const tell = (name, detail = {}) =>
|
|
@@ -556,6 +573,7 @@ export function startOnsite(client, options = {}) {
|
|
|
556
573
|
warmed,
|
|
557
574
|
coupon,
|
|
558
575
|
basket: () => access.basketValue?.() ?? null,
|
|
576
|
+
fill: values ? values.fillFor(form, access, options.values) : undefined,
|
|
559
577
|
emit: tell,
|
|
560
578
|
onClose(why) {
|
|
561
579
|
mounted.delete(form.slot);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Money in `currency` for the language `lang` (else the browser's); null for a currency `Intl` does not know. */
|
|
2
|
+
export function money(lang, currency) {
|
|
3
|
+
try {
|
|
4
|
+
return new Intl.NumberFormat(lang || undefined, {
|
|
5
|
+
style: 'currency',
|
|
6
|
+
currency,
|
|
7
|
+
});
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/onsite/preview.js
CHANGED
|
@@ -10,9 +10,12 @@ import { answerCards, answerFields, answerOf, countdown } from './answers.js';
|
|
|
10
10
|
import { mountProducts } from './products.js';
|
|
11
11
|
import { mountForm } from './render.js';
|
|
12
12
|
import { readCreative } from './rules.js';
|
|
13
|
+
import { fillFor } from './values.js';
|
|
13
14
|
import { mountWheel } from './wheel.js';
|
|
14
15
|
|
|
15
16
|
export { CALLING_CODES } from './rules.js';
|
|
17
|
+
/** A shopper value as the SDK shows it, so the console's examples read as shoppers see them. */
|
|
18
|
+
export { formatValue } from './values.js';
|
|
16
19
|
|
|
17
20
|
const SAMPLE_CONSENT = 'Your consent text appears here.';
|
|
18
21
|
const SAMPLE_SMS_CONSENT = 'Your SMS consent text appears here.';
|
|
@@ -35,7 +38,10 @@ const SAMPLE_BASKET = 32;
|
|
|
35
38
|
* @param {unknown} creative The arm's payload.
|
|
36
39
|
* @param {{ step?: string, device?: 'desktop'|'mobile', consentText?: string, smsConsentText?: string, locale?: string,
|
|
37
40
|
* questions?: Array<{ key: string, type?: 'one'|'many'|'date'|'number', label?: string, options?: Array<{ value: string, label?: string }>, format?: object }>,
|
|
38
|
-
* prizes?: Array<{ key: string, label: string }>, basketValue?: number
|
|
41
|
+
* prizes?: Array<{ key: string, label: string }>, basketValue?: number,
|
|
42
|
+
* values?: { first_name?: string, basket_value?: number, basket_items?: number, currency?: string, answers?: Record<string, string[]> } | null }} [options]
|
|
43
|
+
* `values`: a returning shopper's, for the text's shopper values; without them every value shows
|
|
44
|
+
* its fallback, as for a new visitor, but for an answer given in the form itself.
|
|
39
45
|
* @returns {{ update(creative: unknown, options?: object): void, destroy(): void, step(): string|null }}
|
|
40
46
|
*/
|
|
41
47
|
export function mountPreview(host, creative, options = {}) {
|
|
@@ -48,19 +54,25 @@ export function mountPreview(host, creative, options = {}) {
|
|
|
48
54
|
mounted = null;
|
|
49
55
|
if (!read) return;
|
|
50
56
|
const format = read.format ?? 'popup';
|
|
57
|
+
const form = {
|
|
58
|
+
slot: 'preview',
|
|
59
|
+
format,
|
|
60
|
+
// The settings' `locale`, so the preview orders a birthday and picks a phone's
|
|
61
|
+
// country as shoppers see them, not by the console's own language.
|
|
62
|
+
...(opts.locale ? { locale: opts.locale } : {}),
|
|
63
|
+
consent_text: { text: opts.consentText ?? SAMPLE_CONSENT },
|
|
64
|
+
sms_consent_text: { text: opts.smsConsentText ?? SAMPLE_SMS_CONSENT },
|
|
65
|
+
questions: Array.isArray(opts.questions) ? opts.questions : [],
|
|
66
|
+
prizes: Array.isArray(opts.prizes) ? opts.prizes : [],
|
|
67
|
+
};
|
|
68
|
+
const values = opts.values ?? null;
|
|
51
69
|
mounted = mountForm({
|
|
52
|
-
form
|
|
53
|
-
slot: 'preview',
|
|
54
|
-
format,
|
|
55
|
-
// The settings' `locale`, so the preview orders a birthday and picks a phone's
|
|
56
|
-
// country as shoppers see them, not by the console's own language.
|
|
57
|
-
...(opts.locale ? { locale: opts.locale } : {}),
|
|
58
|
-
consent_text: { text: opts.consentText ?? SAMPLE_CONSENT },
|
|
59
|
-
sms_consent_text: { text: opts.smsConsentText ?? SAMPLE_SMS_CONSENT },
|
|
60
|
-
questions: Array.isArray(opts.questions) ? opts.questions : [],
|
|
61
|
-
prizes: Array.isArray(opts.prizes) ? opts.prizes : [],
|
|
62
|
-
},
|
|
70
|
+
form,
|
|
63
71
|
creative: read,
|
|
72
|
+
fill: fillFor(form, {
|
|
73
|
+
// A new visitor: nothing known yet, though the answers they give in the form show.
|
|
74
|
+
values: () => values ?? { answers: {} },
|
|
75
|
+
}),
|
|
64
76
|
decision: { slot: 'preview' },
|
|
65
77
|
access: silent,
|
|
66
78
|
client: null,
|
package/src/onsite/products.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { watchViewability } from '../auto/viewability.js';
|
|
13
|
+
import { money } from './money.js';
|
|
13
14
|
import { httpsHref } from './rich-text.js';
|
|
14
15
|
import { isObject } from './rules.js';
|
|
15
16
|
|
|
@@ -42,15 +43,8 @@ const ALIGNS = ['left', 'center', 'right'];
|
|
|
42
43
|
|
|
43
44
|
/** Money in the form's language; the bare amount for a currency the browser does not know. */
|
|
44
45
|
function moneyIn(lang, currency) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
style: 'currency',
|
|
48
|
-
currency,
|
|
49
|
-
});
|
|
50
|
-
return (n) => format.format(n);
|
|
51
|
-
} catch {
|
|
52
|
-
return (n) => n.toFixed(2);
|
|
53
|
-
}
|
|
46
|
+
const format = money(lang, currency);
|
|
47
|
+
return format ? (n) => format.format(n) : (n) => n.toFixed(2);
|
|
54
48
|
}
|
|
55
49
|
|
|
56
50
|
/**
|
package/src/onsite/render.js
CHANGED
|
@@ -352,6 +352,8 @@ function styles(root) {
|
|
|
352
352
|
* @param {(name: 'submitted'|'apply', detail: object) => void} [o.emit] To `hp.onsite.on` listeners.
|
|
353
353
|
* @param {string|null} [o.coupon] A code the form starts with: an unused one to remind of.
|
|
354
354
|
* @param {() => number|null} [o.basket] The basket's value, for a progress block.
|
|
355
|
+
* @param {(block: object, answers: object) => object|false} [o.fill] A form whose text has shopper
|
|
356
|
+
* values: each block as it shows (./values.js `fillFor`), `false` for one hidden.
|
|
355
357
|
* @param {{ device: 'desktop'|'mobile', step?: string }} [o.preview] Render inside `container`,
|
|
356
358
|
* inert but for moving between steps: no network, no events, no counters.
|
|
357
359
|
*/
|
|
@@ -1002,11 +1004,9 @@ export function mountForm(o) {
|
|
|
1002
1004
|
}
|
|
1003
1005
|
|
|
1004
1006
|
/** A teaser's first step: a tab that opens the rest of the form. */
|
|
1005
|
-
function tab(
|
|
1006
|
-
const heading =
|
|
1007
|
-
const open =
|
|
1008
|
-
(b) => b?.t === 'button' && b.action === 'next',
|
|
1009
|
-
);
|
|
1007
|
+
function tab(blocks) {
|
|
1008
|
+
const heading = blocks.find((b) => b?.t === 'heading');
|
|
1009
|
+
const open = blocks.find((b) => b?.t === 'button' && b.action === 'next');
|
|
1010
1010
|
// The heading's words without its marks: a button holds no link.
|
|
1011
1011
|
const b = el('button', 'tb');
|
|
1012
1012
|
b.textContent = heading
|
|
@@ -1075,9 +1075,11 @@ export function mountForm(o) {
|
|
|
1075
1075
|
wrap.classList.add('lk');
|
|
1076
1076
|
}
|
|
1077
1077
|
body.replaceChildren();
|
|
1078
|
-
|
|
1078
|
+
// Shopper values filled in as text (./values.js), from this form's answers too; false: hidden.
|
|
1079
|
+
const blocks = step.blocks.map((b) => o.fill?.(b, state.answers) ?? b);
|
|
1080
|
+
if (asTab && l.template !== 'image-only') body.append(tab(blocks));
|
|
1079
1081
|
else
|
|
1080
|
-
for (const [index, block] of
|
|
1082
|
+
for (const [index, block] of blocks.entries()) {
|
|
1081
1083
|
const node =
|
|
1082
1084
|
isObject(block) && Object.hasOwn(BLOCKS, block.t)
|
|
1083
1085
|
? BLOCKS[block.t](block)
|
package/src/onsite/rich-text.js
CHANGED
|
@@ -5,7 +5,18 @@
|
|
|
5
5
|
* RichText = string | Array<string | { b: RichText } | { i: RichText } | { a: RichText, href }>
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
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
|
+
}
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* limited and the visitor allowed personalisation.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
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,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).
|