@thg-agentic/hyper-personalisation-web 3.32.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 +44 -6
- 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-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +1 -1
- package/src/index.js +4 -0
- 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 +66 -4
- package/src/onsite/page-facts.js +61 -0
- package/src/onsite/preview.js +5 -2
- package/src/onsite/render.js +5 -2
- package/src/onsite/rules.js +47 -8
- package/src/onsite-facade.js +37 -0
- package/src/version.js +1 -1
- package/types/onsite-preview.d.ts +13 -0
- package/types/onsite.d.ts +34 -1
package/src/onsite/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* startOnsite(hp); // the client bootstrap resolved to, or pass `onsite: true` to bootstrap
|
|
7
7
|
*
|
|
8
8
|
* The browser judges the trigger; the server decides the rest. Each live form's trigger is watched
|
|
9
|
-
* here, with its pages, devices, dates and caps. When one fires, `POST /v1/decisions` is asked for
|
|
9
|
+
* here, with its pages, devices, dates, daily cut-off and caps. When one fires, `POST /v1/decisions` is asked for
|
|
10
10
|
* that one slot: a held-out visitor, or no decision, shows nothing. One overlay per page view, the
|
|
11
11
|
* highest priority among those that fire within 50 ms; one that cannot show passes to the next, but
|
|
12
12
|
* a held-out visitor sees none, and once one has shown, closing it lets no other in; with the
|
|
@@ -21,6 +21,7 @@ import { loadAnswers } from './answers-loader.js';
|
|
|
21
21
|
import { createApi } from './api.js';
|
|
22
22
|
import { loadAudiences } from './audiences-loader.js';
|
|
23
23
|
import { createCounters } from './counters.js';
|
|
24
|
+
import { factsFor, pageFacts } from './page-facts.js';
|
|
24
25
|
import { loadProducts } from './products-loader.js';
|
|
25
26
|
import { loadRender } from './render-loader.js';
|
|
26
27
|
import { holdsValue } from './rich-text.js';
|
|
@@ -30,9 +31,12 @@ import {
|
|
|
30
31
|
byPriority,
|
|
31
32
|
deviceOf,
|
|
32
33
|
INTERRUPTS,
|
|
34
|
+
OUTSIDE_WINDOW,
|
|
33
35
|
OVERLAY,
|
|
36
|
+
opensIn,
|
|
34
37
|
readCreative,
|
|
35
38
|
readForm,
|
|
39
|
+
UNSAID,
|
|
36
40
|
} from './rules.js';
|
|
37
41
|
import { watch } from './triggers.js';
|
|
38
42
|
import { loadValues } from './values-loader.js';
|
|
@@ -48,6 +52,9 @@ const PENDING = Symbol.for(
|
|
|
48
52
|
const LISTENERS = Symbol.for(
|
|
49
53
|
'@thg-agentic/hyper-personalisation-web.onsite.listeners',
|
|
50
54
|
);
|
|
55
|
+
const PAGE_SAID = Symbol.for(
|
|
56
|
+
'@thg-agentic/hyper-personalisation-web.onsite.page-said',
|
|
57
|
+
);
|
|
51
58
|
const SUPPRESSED = Symbol.for(
|
|
52
59
|
'@thg-agentic/hyper-personalisation-web.onsite.suppressed',
|
|
53
60
|
);
|
|
@@ -226,6 +233,11 @@ export function startOnsite(client, options = {}) {
|
|
|
226
233
|
};
|
|
227
234
|
/** Forms whose trigger fired on an empty basket: they try again when the basket changes. */
|
|
228
235
|
const awaitingBasket = new Set();
|
|
236
|
+
/**
|
|
237
|
+
* Teasers' tabs kept from an earlier page, held back only until the page says what it is about
|
|
238
|
+
* (its facts, its product), which it may do after onsite starts: they try again when it does.
|
|
239
|
+
*/
|
|
240
|
+
const awaitingPage = new Set();
|
|
229
241
|
/** Covering forms whose trigger fired before the consent banner was answered. */
|
|
230
242
|
const awaitingConsent = new Map();
|
|
231
243
|
|
|
@@ -289,6 +301,9 @@ export function startOnsite(client, options = {}) {
|
|
|
289
301
|
path: location.pathname,
|
|
290
302
|
device,
|
|
291
303
|
campaign: campaign(),
|
|
304
|
+
// What the page says, and the product it is about: both held in memory, cheap to read.
|
|
305
|
+
facts: pageFacts(),
|
|
306
|
+
product: access.pageProduct?.(),
|
|
292
307
|
now: Date.now(),
|
|
293
308
|
seen: counters.seen(form.slot),
|
|
294
309
|
subscribed: counters.subscribed() || options.subscribed?.() === true,
|
|
@@ -357,8 +372,30 @@ export function startOnsite(client, options = {}) {
|
|
|
357
372
|
asked,
|
|
358
373
|
});
|
|
359
374
|
if (reasons.length) {
|
|
375
|
+
if (kept && reasons.every((r) => UNSAID.includes(r)))
|
|
376
|
+
awaitingPage.add(form);
|
|
377
|
+
// A trigger that fired before the form's daily window opened is watched again once it
|
|
378
|
+
// opens, so the shopper's next exit (or the page's entry) still counts.
|
|
379
|
+
if (
|
|
380
|
+
reasons.length === 1 &&
|
|
381
|
+
reasons[0] === OUTSIDE_WINDOW &&
|
|
382
|
+
!asked &&
|
|
383
|
+
!kept &&
|
|
384
|
+
!stopped
|
|
385
|
+
) {
|
|
386
|
+
const inline =
|
|
387
|
+
form.trigger.type === 'inline' || form.format === 'inline';
|
|
388
|
+
// In place of any watcher it still has (the site opened it by hand), never beside it.
|
|
389
|
+
unwatch(form);
|
|
390
|
+
const later = setTimeout(
|
|
391
|
+
() => (inline ? show(form, 'inline') : arm(form)),
|
|
392
|
+
opensIn(form, Date.now()),
|
|
393
|
+
);
|
|
394
|
+
stops.set(form.slot, () => clearTimeout(later));
|
|
395
|
+
}
|
|
360
396
|
return note(form, 'not eligible', 'not-eligible', reasons);
|
|
361
397
|
}
|
|
398
|
+
awaitingPage.delete(form);
|
|
362
399
|
// The gap first: it holds for the whole session, so it is the reason worth reading.
|
|
363
400
|
const interrupts = INTERRUPTS(form.format) && !kept;
|
|
364
401
|
const wait = interrupts && !asked && gapHolds();
|
|
@@ -442,11 +479,17 @@ export function startOnsite(client, options = {}) {
|
|
|
442
479
|
api.decide(form.slot, counters.pin(form.slot), {
|
|
443
480
|
visitAudiences: claimed,
|
|
444
481
|
visitCampaign: form.arrived_from.length ? campaign() : null,
|
|
445
|
-
// Only for a form whose products are filled from it
|
|
482
|
+
// Only for a form whose products are filled from it, or limited to products (then only
|
|
483
|
+
// the page's product); never kept or logged by the server.
|
|
446
484
|
visitContext:
|
|
447
485
|
form.visit_context === true
|
|
448
486
|
? (access.visitContext?.() ?? null)
|
|
449
|
-
:
|
|
487
|
+
: form.products
|
|
488
|
+
? { product_id: access.pageProduct?.() }
|
|
489
|
+
: null,
|
|
490
|
+
pageFacts: form.page_facts.length
|
|
491
|
+
? factsFor(form.page_facts, pageFacts())
|
|
492
|
+
: null,
|
|
450
493
|
timeoutMs: deadline - Date.now(),
|
|
451
494
|
}),
|
|
452
495
|
deadline,
|
|
@@ -651,7 +694,10 @@ export function startOnsite(client, options = {}) {
|
|
|
651
694
|
show(form, 'tab kept from an earlier page', true);
|
|
652
695
|
continue;
|
|
653
696
|
}
|
|
654
|
-
|
|
697
|
+
// What the page says, which product it is about, and whether its daily window is open may
|
|
698
|
+
// all change while the shopper is on it, so a form waits on its trigger for them; they are
|
|
699
|
+
// judged when it fires.
|
|
700
|
+
const reasons = blockers(form, { ...env(form), later: true });
|
|
655
701
|
if (reasons.length) {
|
|
656
702
|
note(form, 'not eligible', 'not-eligible', reasons);
|
|
657
703
|
continue;
|
|
@@ -692,6 +738,20 @@ export function startOnsite(client, options = {}) {
|
|
|
692
738
|
asked ? fire(form, 'manual', asked) : arm(form);
|
|
693
739
|
});
|
|
694
740
|
|
|
741
|
+
const heard = () => {
|
|
742
|
+
const waiting = [...awaitingPage];
|
|
743
|
+
awaitingPage.clear();
|
|
744
|
+
// After any attempt still settling: it judged the page before the page said so.
|
|
745
|
+
for (const form of waiting)
|
|
746
|
+
Promise.resolve(opening.get(form.slot)?.done).then(
|
|
747
|
+
() =>
|
|
748
|
+
stopped ||
|
|
749
|
+
mounted.has(form.slot) ||
|
|
750
|
+
show(form, 'tab kept from an earlier page', true),
|
|
751
|
+
);
|
|
752
|
+
};
|
|
753
|
+
(window[PAGE_SAID] ??= new Set()).add(heard);
|
|
754
|
+
|
|
695
755
|
const stopBasket = access.onBasket?.(() => {
|
|
696
756
|
if (!((access.basketValue?.() ?? 0) > 0)) return;
|
|
697
757
|
// Out of the set before the decision is asked, so a second update cannot show them twice;
|
|
@@ -744,6 +804,8 @@ export function startOnsite(client, options = {}) {
|
|
|
744
804
|
stopConsent?.();
|
|
745
805
|
awaitingBasket.clear();
|
|
746
806
|
awaitingConsent.clear();
|
|
807
|
+
awaitingPage.clear();
|
|
808
|
+
window[PAGE_SAID]?.delete(heard);
|
|
747
809
|
if (window[CONTROLLER] === controller) delete window[CONTROLLER];
|
|
748
810
|
},
|
|
749
811
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page facts (#1164): what the page says about itself, given with `hp.onsite.facts({ … })`, such as
|
|
3
|
+
* whether the product on it can be delivered tomorrow. Kept in the page's memory for the path it was
|
|
4
|
+
* given on, so a single-page app's next page starts with none; an anchor or a variant's query on the
|
|
5
|
+
* same path keeps them. A form's
|
|
6
|
+
* `page_facts` rules are judged here and again by the public API, which reads the facts the request carries (only those the rules
|
|
7
|
+
* read) and drops any that are not a fact: both run spec/onsite/examples/page-facts.cases.json.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Where `hp.onsite.facts` (../onsite-facade.js) keeps them: the page's, not this chunk's. */
|
|
11
|
+
const PAGE_FACTS = Symbol.for(
|
|
12
|
+
'@thg-agentic/hyper-personalisation-web.onsite.page-facts',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
/** The facts this page gave, on the path it is on now; `{}` when none. */
|
|
16
|
+
export function pageFacts(win = globalThis.window) {
|
|
17
|
+
const held = win?.[PAGE_FACTS];
|
|
18
|
+
return held?.path === win?.location?.pathname ? held.facts : {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A fact as the public API reads it: words up to 100 characters. */
|
|
22
|
+
const read = (v) => (typeof v === 'string' ? v.slice(0, 100) : v);
|
|
23
|
+
|
|
24
|
+
const norm = (v) => (typeof v === 'string' ? v.trim().toLowerCase() : v);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether `facts` meets every rule. A fact the page did not give, or gave as another kind, meets no
|
|
28
|
+
* rule on it, `is_not` included; a rule this SDK cannot read meets none. No rules is any page. The
|
|
29
|
+
* control plane's `pageFactsHold` is the same.
|
|
30
|
+
*
|
|
31
|
+
* @param {Array<{ fact: string, op: string, values: unknown[] }>} rules
|
|
32
|
+
* @param {Record<string, unknown> | null} facts
|
|
33
|
+
*/
|
|
34
|
+
export const pageFactsHold = (rules, facts) =>
|
|
35
|
+
rules.every((rule) => {
|
|
36
|
+
// As the public API reads them, and only finite numbers.
|
|
37
|
+
const have = read(facts?.[rule.fact]);
|
|
38
|
+
const values = Array.isArray(rule.values) ? rule.values : [];
|
|
39
|
+
if (
|
|
40
|
+
have == null ||
|
|
41
|
+
typeof have !== typeof values[0] ||
|
|
42
|
+
(typeof have === 'number' && !Number.isFinite(have))
|
|
43
|
+
)
|
|
44
|
+
return false;
|
|
45
|
+
const any = values.some((want) => norm(want) === norm(have));
|
|
46
|
+
return rule.op === 'is'
|
|
47
|
+
? any
|
|
48
|
+
: rule.op === 'is_not'
|
|
49
|
+
? !any
|
|
50
|
+
: rule.op === 'at_least'
|
|
51
|
+
? have >= values[0]
|
|
52
|
+
: rule.op === 'at_most' && have <= values[0];
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Only the facts `rules` read, for the decision request: nothing else the page gave leaves it. */
|
|
56
|
+
export const factsFor = (rules, facts) =>
|
|
57
|
+
Object.fromEntries(
|
|
58
|
+
rules
|
|
59
|
+
.filter((r) => r.fact in facts)
|
|
60
|
+
.map((r) => [r.fact, read(facts[r.fact])]),
|
|
61
|
+
);
|
package/src/onsite/preview.js
CHANGED
|
@@ -38,10 +38,12 @@ const SAMPLE_BASKET = 32;
|
|
|
38
38
|
* @param {unknown} creative The arm's payload.
|
|
39
39
|
* @param {{ step?: string, device?: 'desktop'|'mobile', consentText?: string, smsConsentText?: string, locale?: string,
|
|
40
40
|
* questions?: Array<{ key: string, type?: 'one'|'many'|'date'|'number', label?: string, options?: Array<{ value: string, label?: string }>, format?: object }>,
|
|
41
|
-
* prizes?: Array<{ key: string, label: string }>, basketValue?: number,
|
|
41
|
+
* prizes?: Array<{ key: string, label: string }>, basketValue?: number, daily?: object,
|
|
42
42
|
* values?: { first_name?: string, basket_value?: number, basket_items?: number, currency?: string, answers?: Record<string, string[]> } | null }} [options]
|
|
43
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.
|
|
44
|
+
* its fallback, as for a new visitor, but for an answer given in the form itself. `daily`: the
|
|
45
|
+
* form's daily cut-off as the settings carry it (`window.daily`, its moments), which a `daily`
|
|
46
|
+
* countdown counts to.
|
|
45
47
|
* @returns {{ update(creative: unknown, options?: object): void, destroy(): void, step(): string|null }}
|
|
46
48
|
*/
|
|
47
49
|
export function mountPreview(host, creative, options = {}) {
|
|
@@ -64,6 +66,7 @@ export function mountPreview(host, creative, options = {}) {
|
|
|
64
66
|
sms_consent_text: { text: opts.smsConsentText ?? SAMPLE_SMS_CONSENT },
|
|
65
67
|
questions: Array.isArray(opts.questions) ? opts.questions : [],
|
|
66
68
|
prizes: Array.isArray(opts.prizes) ? opts.prizes : [],
|
|
69
|
+
window: opts.daily ? { daily: opts.daily } : {},
|
|
67
70
|
};
|
|
68
71
|
const values = opts.values ?? null;
|
|
69
72
|
mounted = mountForm({
|
package/src/onsite/render.js
CHANGED
|
@@ -761,7 +761,8 @@ export function mountForm(o) {
|
|
|
761
761
|
|
|
762
762
|
/**
|
|
763
763
|
* A countdown's place: the answers chunk draws its label and timer into it and ticks it
|
|
764
|
-
* (./countdown.js), showing it, and hides it again once a
|
|
764
|
+
* (./countdown.js), a daily one to the form's next cut-off, showing it, and hides it again once a
|
|
765
|
+
* deadline passes. Hidden until then, so
|
|
765
766
|
* a chunk that never comes leaves nothing behind, not an empty timer.
|
|
766
767
|
*/
|
|
767
768
|
function countdown(block) {
|
|
@@ -773,7 +774,9 @@ export function mountForm(o) {
|
|
|
773
774
|
(m) =>
|
|
774
775
|
m &&
|
|
775
776
|
box.isConnected &&
|
|
776
|
-
state.timers.push(
|
|
777
|
+
state.timers.push(
|
|
778
|
+
m.countdown(box, block, preview, el, form.window.daily?.next),
|
|
779
|
+
),
|
|
777
780
|
);
|
|
778
781
|
return box;
|
|
779
782
|
}
|
package/src/onsite/rules.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { arrivedFrom } from '../visit-campaign.js';
|
|
7
|
+
import { pageFactsHold } from './page-facts.js';
|
|
8
|
+
|
|
9
|
+
/** What holds a form back until the page says what it is about: its facts, or its product. */
|
|
10
|
+
export const UNSAID = ['the page does not say so', 'not a product page'];
|
|
7
11
|
|
|
8
12
|
export const FORMATS = [
|
|
9
13
|
'popup',
|
|
@@ -41,10 +45,12 @@ const strings = (v) =>
|
|
|
41
45
|
Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
|
|
42
46
|
const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
|
|
43
47
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Rules as the runtime reads them: none when absent, and ones this SDK cannot read become `never`, a
|
|
50
|
+
* rule no visit or page meets, so the form fails closed.
|
|
51
|
+
*/
|
|
52
|
+
const rulesOr = (raw, never) =>
|
|
53
|
+
raw == null ? [] : Array.isArray(raw) && raw.every(isObject) ? raw : [never];
|
|
48
54
|
|
|
49
55
|
/** A form from the settings as the runtime uses it, or null when it cannot be used. */
|
|
50
56
|
export function readForm(raw) {
|
|
@@ -62,7 +68,13 @@ export function readForm(raw) {
|
|
|
62
68
|
devices: strings(raw.devices),
|
|
63
69
|
aliases: strings(raw.aliases),
|
|
64
70
|
audiences: strings(raw.audiences),
|
|
65
|
-
arrived_from:
|
|
71
|
+
arrived_from: rulesOr(raw.arrived_from, {
|
|
72
|
+
field: null,
|
|
73
|
+
match: null,
|
|
74
|
+
values: [],
|
|
75
|
+
}),
|
|
76
|
+
page_facts: rulesOr(raw.page_facts, { fact: null, op: null, values: [] }),
|
|
77
|
+
products: raw.products === true,
|
|
66
78
|
caps: {
|
|
67
79
|
per_session: count(caps.per_session),
|
|
68
80
|
lifetime: count(caps.lifetime),
|
|
@@ -136,10 +148,20 @@ const at = (iso) => (typeof iso === 'string' ? Date.parse(iso) : Number.NaN);
|
|
|
136
148
|
*/
|
|
137
149
|
export const ASKED = (form) => form.trigger.type === 'manual';
|
|
138
150
|
|
|
151
|
+
/** A form held back only until its daily window opens; `opensIn` says how long that is. */
|
|
152
|
+
export const OUTSIDE_WINDOW = 'outside its daily window';
|
|
153
|
+
export const opensIn = (form, now) => {
|
|
154
|
+
const { daily } = form.window;
|
|
155
|
+
return daily.next.find((t) => t > now) - daily.within_hours * 36e5 - now;
|
|
156
|
+
};
|
|
157
|
+
|
|
139
158
|
/**
|
|
140
|
-
* Every reason `form` may not show now, empty when it may
|
|
141
|
-
*
|
|
142
|
-
* `
|
|
159
|
+
* Every reason `form` may not show now, empty when it may: its pages, devices, campaign, page
|
|
160
|
+
* facts and products, dates and daily cut-off, audiences and counters. `env` is the page:
|
|
161
|
+
* `{ path, device, campaign, facts, product, now, seen, subscribed, personalisation, preview,
|
|
162
|
+
* audiences }`, where `campaign` is the visit's (`visitCampaign`), `facts` what the page said
|
|
163
|
+
* about itself (`hp.onsite.facts`), `product` the id of the product the page is about (its last
|
|
164
|
+
* Product Viewed), `seen` the slot's counters and `audiences` the
|
|
143
165
|
* session audiences the host gave, or null. The campaign is context about this visit, as the page
|
|
144
166
|
* is, so it needs no personalisation consent. A form limited to audiences needs personalisation
|
|
145
167
|
* consent; whether the visitor is in one is the server's to decide (`POST /v1/decisions`), unless
|
|
@@ -152,9 +174,26 @@ export function blockers(form, env) {
|
|
|
152
174
|
reasons.push(`not on ${env.device}`);
|
|
153
175
|
if (!arrivedFrom(form.arrived_from, env.campaign))
|
|
154
176
|
reasons.push('not from its campaign');
|
|
177
|
+
// A preview shows the version asked for on any page, as the server answers it.
|
|
178
|
+
if (!env.later && !env.preview) {
|
|
179
|
+
if (!pageFactsHold(form.page_facts, env.facts)) reasons.push(UNSAID[0]);
|
|
180
|
+
// Which products: the server's to decide; a page about none can never be one of them.
|
|
181
|
+
if (form.products && !env.product) reasons.push(UNSAID[1]);
|
|
182
|
+
}
|
|
155
183
|
const starts = at(form.window.starts_at);
|
|
156
184
|
const ends = at(form.window.ends_at);
|
|
157
185
|
if (env.now < starts || env.now >= ends) reasons.push('outside its dates');
|
|
186
|
+
// Its daily cut-off is targeting too: checked before any decision, so a held-out visitor is
|
|
187
|
+
// filtered as a shown one is. Its window is judged when the trigger fires (`later` at start), as
|
|
188
|
+
// it may open while the shopper is on the page; with no cut-off ahead it never can. The server
|
|
189
|
+
// lists the moments; a trader previewing sees it at any hour.
|
|
190
|
+
const { daily } = form.window;
|
|
191
|
+
if (daily && !env.preview) {
|
|
192
|
+
const left = daily.next?.find?.((t) => t > env.now) - env.now;
|
|
193
|
+
if (!(left > 0)) reasons.push('no cut-off ahead');
|
|
194
|
+
else if (!env.later && left > daily.within_hours * 36e5)
|
|
195
|
+
reasons.push(OUTSIDE_WINDOW);
|
|
196
|
+
}
|
|
158
197
|
// A trader previewing a limited form sees it, as the server answers a preview.
|
|
159
198
|
if (form.audiences.length && !env.preview) {
|
|
160
199
|
if (!env.personalisation)
|
package/src/onsite-facade.js
CHANGED
|
@@ -19,11 +19,27 @@ const SUPPRESSED = Symbol.for(
|
|
|
19
19
|
const PAGE_VALUES = Symbol.for(
|
|
20
20
|
'@thg-agentic/hyper-personalisation-web.onsite.page-values',
|
|
21
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
|
+
);
|
|
22
29
|
const EVENTS = ['shown', 'submitted', 'apply', 'closed', 'held', 'nothing'];
|
|
23
30
|
const MAX_PENDING = 10;
|
|
24
31
|
|
|
25
32
|
const page = () => (typeof window === 'undefined' ? null : window);
|
|
26
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
|
+
|
|
27
43
|
/** No onsite runtime will start on this page: every `open` waiting for one resolves `false`. */
|
|
28
44
|
export function dropPendingOpens() {
|
|
29
45
|
try {
|
|
@@ -111,6 +127,27 @@ export const onsite = Object.freeze({
|
|
|
111
127
|
/* never into the page */
|
|
112
128
|
}
|
|
113
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
|
+
},
|
|
114
151
|
/**
|
|
115
152
|
* Each form's state, as `debug`, once the page knows whether onsite forms run on it: when the
|
|
116
153
|
* runtime has started, or `[]` once none will (not opted in, no live form, the chunk failed).
|
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,8 @@ 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;
|
|
37
39
|
/**
|
|
38
40
|
* A returning shopper's values for the text's shopper values (`answers` by question, as option
|
|
39
41
|
* values); null or left out, every value shows its fallback, as a new visitor sees it.
|
|
@@ -83,3 +85,14 @@ export declare function formatValue(
|
|
|
83
85
|
now?: number;
|
|
84
86
|
},
|
|
85
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
|
@@ -31,6 +31,16 @@ export interface OnsiteValueNode {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
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
|
+
|
|
34
44
|
/** What the page knows of the shopper for a form's text. Never sent, stored or logged. */
|
|
35
45
|
export interface OnsitePageValues {
|
|
36
46
|
first_name?: string;
|
|
@@ -85,11 +95,26 @@ export interface OnsiteForm {
|
|
|
85
95
|
match: 'is' | 'contains';
|
|
86
96
|
values: string[];
|
|
87
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;
|
|
88
105
|
/** Shown only with personalisation consent, and only to members as the server decides. */
|
|
89
106
|
audiences?: string[];
|
|
90
107
|
exclude_subscribed?: boolean;
|
|
91
108
|
caps?: { per_session?: number; lifetime?: number; after_close_days?: number };
|
|
92
|
-
|
|
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
|
+
};
|
|
93
118
|
/**
|
|
94
119
|
* The form's language, BCP 47 (`en-IE`): its phone field starts on this region's country code,
|
|
95
120
|
* and a birthday's day and month follow its order and month names. Absent: the page's `lang`.
|
|
@@ -328,6 +353,14 @@ export interface OnsiteFacade {
|
|
|
328
353
|
* memory only, never sent, stored or logged. Each call replaces the last; `values({})` forgets.
|
|
329
354
|
*/
|
|
330
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;
|
|
331
364
|
/** Each form's state once the page knows whether onsite forms run on it; `[]` when none will. */
|
|
332
365
|
ready(): Promise<OnsiteTrace[]>;
|
|
333
366
|
debug(): OnsiteTrace[];
|