@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
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,19 +21,25 @@ 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';
|
|
27
|
+
import { holdsValue } from './rich-text.js';
|
|
26
28
|
import {
|
|
27
29
|
ASKED,
|
|
28
30
|
blockers,
|
|
29
31
|
byPriority,
|
|
30
32
|
deviceOf,
|
|
31
33
|
INTERRUPTS,
|
|
34
|
+
OUTSIDE_WINDOW,
|
|
32
35
|
OVERLAY,
|
|
36
|
+
opensIn,
|
|
33
37
|
readCreative,
|
|
34
38
|
readForm,
|
|
39
|
+
UNSAID,
|
|
35
40
|
} from './rules.js';
|
|
36
41
|
import { watch } from './triggers.js';
|
|
42
|
+
import { loadValues } from './values-loader.js';
|
|
37
43
|
import { loadWheel } from './wheel-loader.js';
|
|
38
44
|
|
|
39
45
|
const ACCESS = Symbol.for(
|
|
@@ -46,6 +52,9 @@ const PENDING = Symbol.for(
|
|
|
46
52
|
const LISTENERS = Symbol.for(
|
|
47
53
|
'@thg-agentic/hyper-personalisation-web.onsite.listeners',
|
|
48
54
|
);
|
|
55
|
+
const PAGE_SAID = Symbol.for(
|
|
56
|
+
'@thg-agentic/hyper-personalisation-web.onsite.page-said',
|
|
57
|
+
);
|
|
49
58
|
const SUPPRESSED = Symbol.for(
|
|
50
59
|
'@thg-agentic/hyper-personalisation-web.onsite.suppressed',
|
|
51
60
|
);
|
|
@@ -75,6 +84,7 @@ const NOTHING = new Set([
|
|
|
75
84
|
'no-code',
|
|
76
85
|
'renderer-failed',
|
|
77
86
|
'products-failed',
|
|
87
|
+
'values-failed',
|
|
78
88
|
'error',
|
|
79
89
|
]);
|
|
80
90
|
|
|
@@ -102,14 +112,9 @@ const debugParam = () => {
|
|
|
102
112
|
}
|
|
103
113
|
};
|
|
104
114
|
|
|
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
|
-
);
|
|
115
|
+
/** Whether any block of any of the creative's steps meets `uses`. */
|
|
116
|
+
const stepsUse = (creative, uses) =>
|
|
117
|
+
creative.steps.some((step) => step.blocks.some(uses));
|
|
113
118
|
|
|
114
119
|
/**
|
|
115
120
|
* Start onsite forms for `client`, once per page. Returns the controller, or null without a
|
|
@@ -228,6 +233,11 @@ export function startOnsite(client, options = {}) {
|
|
|
228
233
|
};
|
|
229
234
|
/** Forms whose trigger fired on an empty basket: they try again when the basket changes. */
|
|
230
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();
|
|
231
241
|
/** Covering forms whose trigger fired before the consent banner was answered. */
|
|
232
242
|
const awaitingConsent = new Map();
|
|
233
243
|
|
|
@@ -291,6 +301,9 @@ export function startOnsite(client, options = {}) {
|
|
|
291
301
|
path: location.pathname,
|
|
292
302
|
device,
|
|
293
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?.(),
|
|
294
307
|
now: Date.now(),
|
|
295
308
|
seen: counters.seen(form.slot),
|
|
296
309
|
subscribed: counters.subscribed() || options.subscribed?.() === true,
|
|
@@ -359,8 +372,30 @@ export function startOnsite(client, options = {}) {
|
|
|
359
372
|
asked,
|
|
360
373
|
});
|
|
361
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
|
+
}
|
|
362
396
|
return note(form, 'not eligible', 'not-eligible', reasons);
|
|
363
397
|
}
|
|
398
|
+
awaitingPage.delete(form);
|
|
364
399
|
// The gap first: it holds for the whole session, so it is the reason worth reading.
|
|
365
400
|
const interrupts = INTERRUPTS(form.format) && !kept;
|
|
366
401
|
const wait = interrupts && !asked && gapHolds();
|
|
@@ -444,11 +479,17 @@ export function startOnsite(client, options = {}) {
|
|
|
444
479
|
api.decide(form.slot, counters.pin(form.slot), {
|
|
445
480
|
visitAudiences: claimed,
|
|
446
481
|
visitCampaign: form.arrived_from.length ? campaign() : null,
|
|
447
|
-
// 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.
|
|
448
484
|
visitContext:
|
|
449
485
|
form.visit_context === true
|
|
450
486
|
? (access.visitContext?.() ?? null)
|
|
451
|
-
:
|
|
487
|
+
: form.products
|
|
488
|
+
? { product_id: access.pageProduct?.() }
|
|
489
|
+
: null,
|
|
490
|
+
pageFacts: form.page_facts.length
|
|
491
|
+
? factsFor(form.page_facts, pageFacts())
|
|
492
|
+
: null,
|
|
452
493
|
timeoutMs: deadline - Date.now(),
|
|
453
494
|
}),
|
|
454
495
|
deadline,
|
|
@@ -521,20 +562,36 @@ export function startOnsite(client, options = {}) {
|
|
|
521
562
|
return drop(form, 'not shown', 'renderer-failed', [
|
|
522
563
|
'the renderer did not load',
|
|
523
564
|
]);
|
|
524
|
-
// Its products
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
]);
|
|
531
|
-
}
|
|
532
|
-
// Its first step's pictures fetched and decoded first, so it never opens on an empty one, and
|
|
533
|
-
// never waits for them longer than WARM_MS or past its deadline.
|
|
534
|
-
await within(
|
|
565
|
+
// Its products and its text's values are drawn by chunks of their own, fetched together while
|
|
566
|
+
// its first step's pictures warm: without them the form would open on an empty block, or on
|
|
567
|
+
// text with a gap. The pictures are waited for no longer than WARM_MS or past its deadline.
|
|
568
|
+
const products = stepsUse(creative, (b) => b?.t === 'products');
|
|
569
|
+
if (products) cards ??= loadProducts().catch(() => null);
|
|
570
|
+
const warming = within(
|
|
535
571
|
render.warm?.(form, creative, creative.steps[0].id, warmed),
|
|
536
572
|
Math.min(deadline, Date.now() + WARM_MS),
|
|
537
573
|
);
|
|
574
|
+
const [drawn, values] = await Promise.all([
|
|
575
|
+
!products || within(cards, deadline, null),
|
|
576
|
+
stepsUse(
|
|
577
|
+
creative,
|
|
578
|
+
(b) => (b?.t === 'heading' || b?.t === 'text') && holdsValue(b.text),
|
|
579
|
+
) &&
|
|
580
|
+
within(
|
|
581
|
+
loadValues().catch(() => null),
|
|
582
|
+
deadline,
|
|
583
|
+
null,
|
|
584
|
+
),
|
|
585
|
+
]);
|
|
586
|
+
if (!drawn)
|
|
587
|
+
return drop(form, 'not shown', 'products-failed', [
|
|
588
|
+
'its products did not load',
|
|
589
|
+
]);
|
|
590
|
+
if (values === null)
|
|
591
|
+
return drop(form, 'not shown', 'values-failed', [
|
|
592
|
+
'its values did not load',
|
|
593
|
+
]);
|
|
594
|
+
await warming;
|
|
538
595
|
if (suppressed())
|
|
539
596
|
return drop(form, 'not shown', 'suppressed', ['suppressed']);
|
|
540
597
|
const tell = (name, detail = {}) =>
|
|
@@ -559,6 +616,7 @@ export function startOnsite(client, options = {}) {
|
|
|
559
616
|
warmed,
|
|
560
617
|
coupon,
|
|
561
618
|
basket: () => access.basketValue?.() ?? null,
|
|
619
|
+
fill: values ? values.fillFor(form, access, options.values) : undefined,
|
|
562
620
|
emit: tell,
|
|
563
621
|
onClose(why) {
|
|
564
622
|
mounted.delete(form.slot);
|
|
@@ -636,7 +694,10 @@ export function startOnsite(client, options = {}) {
|
|
|
636
694
|
show(form, 'tab kept from an earlier page', true);
|
|
637
695
|
continue;
|
|
638
696
|
}
|
|
639
|
-
|
|
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 });
|
|
640
701
|
if (reasons.length) {
|
|
641
702
|
note(form, 'not eligible', 'not-eligible', reasons);
|
|
642
703
|
continue;
|
|
@@ -677,6 +738,20 @@ export function startOnsite(client, options = {}) {
|
|
|
677
738
|
asked ? fire(form, 'manual', asked) : arm(form);
|
|
678
739
|
});
|
|
679
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
|
+
|
|
680
755
|
const stopBasket = access.onBasket?.(() => {
|
|
681
756
|
if (!((access.basketValue?.() ?? 0) > 0)) return;
|
|
682
757
|
// Out of the set before the decision is asked, so a second update cannot show them twice;
|
|
@@ -729,6 +804,8 @@ export function startOnsite(client, options = {}) {
|
|
|
729
804
|
stopConsent?.();
|
|
730
805
|
awaitingBasket.clear();
|
|
731
806
|
awaitingConsent.clear();
|
|
807
|
+
awaitingPage.clear();
|
|
808
|
+
window[PAGE_SAID]?.delete(heard);
|
|
732
809
|
if (window[CONTROLLER] === controller) delete window[CONTROLLER];
|
|
733
810
|
},
|
|
734
811
|
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -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,12 @@ 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, daily?: object,
|
|
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. `daily`: the
|
|
45
|
+
* form's daily cut-off as the settings carry it (`window.daily`, its moments), which a `daily`
|
|
46
|
+
* countdown counts to.
|
|
39
47
|
* @returns {{ update(creative: unknown, options?: object): void, destroy(): void, step(): string|null }}
|
|
40
48
|
*/
|
|
41
49
|
export function mountPreview(host, creative, options = {}) {
|
|
@@ -48,19 +56,26 @@ export function mountPreview(host, creative, options = {}) {
|
|
|
48
56
|
mounted = null;
|
|
49
57
|
if (!read) return;
|
|
50
58
|
const format = read.format ?? 'popup';
|
|
59
|
+
const form = {
|
|
60
|
+
slot: 'preview',
|
|
61
|
+
format,
|
|
62
|
+
// The settings' `locale`, so the preview orders a birthday and picks a phone's
|
|
63
|
+
// country as shoppers see them, not by the console's own language.
|
|
64
|
+
...(opts.locale ? { locale: opts.locale } : {}),
|
|
65
|
+
consent_text: { text: opts.consentText ?? SAMPLE_CONSENT },
|
|
66
|
+
sms_consent_text: { text: opts.smsConsentText ?? SAMPLE_SMS_CONSENT },
|
|
67
|
+
questions: Array.isArray(opts.questions) ? opts.questions : [],
|
|
68
|
+
prizes: Array.isArray(opts.prizes) ? opts.prizes : [],
|
|
69
|
+
window: opts.daily ? { daily: opts.daily } : {},
|
|
70
|
+
};
|
|
71
|
+
const values = opts.values ?? null;
|
|
51
72
|
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
|
-
},
|
|
73
|
+
form,
|
|
63
74
|
creative: read,
|
|
75
|
+
fill: fillFor(form, {
|
|
76
|
+
// A new visitor: nothing known yet, though the answers they give in the form show.
|
|
77
|
+
values: () => values ?? { answers: {} },
|
|
78
|
+
}),
|
|
64
79
|
decision: { slot: 'preview' },
|
|
65
80
|
access: silent,
|
|
66
81
|
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
|
*/
|
|
@@ -759,7 +761,8 @@ export function mountForm(o) {
|
|
|
759
761
|
|
|
760
762
|
/**
|
|
761
763
|
* A countdown's place: the answers chunk draws its label and timer into it and ticks it
|
|
762
|
-
* (./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
|
|
763
766
|
* a chunk that never comes leaves nothing behind, not an empty timer.
|
|
764
767
|
*/
|
|
765
768
|
function countdown(block) {
|
|
@@ -771,7 +774,9 @@ export function mountForm(o) {
|
|
|
771
774
|
(m) =>
|
|
772
775
|
m &&
|
|
773
776
|
box.isConnected &&
|
|
774
|
-
state.timers.push(
|
|
777
|
+
state.timers.push(
|
|
778
|
+
m.countdown(box, block, preview, el, form.window.daily?.next),
|
|
779
|
+
),
|
|
775
780
|
);
|
|
776
781
|
return box;
|
|
777
782
|
}
|
|
@@ -1002,11 +1007,9 @@ export function mountForm(o) {
|
|
|
1002
1007
|
}
|
|
1003
1008
|
|
|
1004
1009
|
/** 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
|
-
);
|
|
1010
|
+
function tab(blocks) {
|
|
1011
|
+
const heading = blocks.find((b) => b?.t === 'heading');
|
|
1012
|
+
const open = blocks.find((b) => b?.t === 'button' && b.action === 'next');
|
|
1010
1013
|
// The heading's words without its marks: a button holds no link.
|
|
1011
1014
|
const b = el('button', 'tb');
|
|
1012
1015
|
b.textContent = heading
|
|
@@ -1075,9 +1078,11 @@ export function mountForm(o) {
|
|
|
1075
1078
|
wrap.classList.add('lk');
|
|
1076
1079
|
}
|
|
1077
1080
|
body.replaceChildren();
|
|
1078
|
-
|
|
1081
|
+
// Shopper values filled in as text (./values.js), from this form's answers too; false: hidden.
|
|
1082
|
+
const blocks = step.blocks.map((b) => o.fill?.(b, state.answers) ?? b);
|
|
1083
|
+
if (asTab && l.template !== 'image-only') body.append(tab(blocks));
|
|
1079
1084
|
else
|
|
1080
|
-
for (const [index, block] of
|
|
1085
|
+
for (const [index, block] of blocks.entries()) {
|
|
1081
1086
|
const node =
|
|
1082
1087
|
isObject(block) && Object.hasOwn(BLOCKS, block.t)
|
|
1083
1088
|
? 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) {
|
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)
|
|
@@ -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');
|