@thg-agentic/hyper-personalisation-web 3.32.0 → 3.35.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 +54 -8
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +2 -2
- 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-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +1 -1
- package/src/datalayer.js +7 -2
- package/src/index.js +55 -3
- 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 +99 -11
- 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 +52 -8
- package/src/onsite-facade.js +37 -0
- package/src/segment.js +2 -1
- package/src/session/record.js +61 -0
- package/src/session/rules.js +3 -1
- package/src/session/signals.js +3 -0
- package/src/shape.js +9 -6
- package/src/version.js +1 -1
- package/types/index.d.ts +1 -1
- package/types/onsite-preview.d.ts +13 -0
- package/types/onsite.d.ts +39 -1
package/src/onsite/answers.js
CHANGED
|
@@ -216,14 +216,17 @@ function style(root, make) {
|
|
|
216
216
|
root.append(node);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
/**
|
|
220
|
-
|
|
219
|
+
/**
|
|
220
|
+
* A countdown block into the renderer's `box`: its label, then its timer, ticked (./countdown.js),
|
|
221
|
+
* a `daily` one to the next of the form's `cutoffs`.
|
|
222
|
+
*/
|
|
223
|
+
export function countdown(box, block, preview, make, cutoffs) {
|
|
221
224
|
style(box.getRootNode(), make);
|
|
222
225
|
if (block.label) box.append(make('p', 'x-md', block.label));
|
|
223
226
|
const timer = make('p', 'tm');
|
|
224
227
|
timer.setAttribute('role', 'timer');
|
|
225
228
|
box.append(timer);
|
|
226
|
-
return tick(box, block, preview);
|
|
229
|
+
return tick(box, block, preview, cutoffs);
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
/**
|
package/src/onsite/api.js
CHANGED
|
@@ -80,8 +80,9 @@ export function createApi(access, options = {}) {
|
|
|
80
80
|
* what the browser knows of the visit: for a form limited to audiences, `visitAudiences`, the
|
|
81
81
|
* session audiences it meets; for a form that names a campaign, `visitCampaign`, the campaign
|
|
82
82
|
* the visit arrived from. The server holds either form back unless its rules hold. For a form
|
|
83
|
-
* that shows products, `visitContext`: the page and the visit its products are filled from
|
|
84
|
-
*
|
|
83
|
+
* that shows products, `visitContext`: the page and the visit its products are filled from; for
|
|
84
|
+
* one limited to products, the page's product. For a form limited by page facts, `pageFacts`:
|
|
85
|
+
* the facts its rules read. Bounded by `timeoutMs`, at most DECISION_TIMEOUT_MS.
|
|
85
86
|
*/
|
|
86
87
|
decide: (
|
|
87
88
|
slot,
|
|
@@ -90,6 +91,7 @@ export function createApi(access, options = {}) {
|
|
|
90
91
|
visitAudiences,
|
|
91
92
|
visitCampaign,
|
|
92
93
|
visitContext,
|
|
94
|
+
pageFacts,
|
|
93
95
|
timeoutMs = DECISION_TIMEOUT_MS,
|
|
94
96
|
} = {},
|
|
95
97
|
) =>
|
|
@@ -121,6 +123,7 @@ export function createApi(access, options = {}) {
|
|
|
121
123
|
}),
|
|
122
124
|
...(visitCampaign ? { visit_campaign: visitCampaign } : {}),
|
|
123
125
|
...(visitContext ? { visit_context: visitContext } : {}),
|
|
126
|
+
...(pageFacts ? { page_facts: pageFacts } : {}),
|
|
124
127
|
},
|
|
125
128
|
}),
|
|
126
129
|
/** The property's audience definitions: the same for everybody, so no consent header. */
|
package/src/onsite/countdown.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An onsite form's countdown block, ticking in the node the renderer made: to a deadline
|
|
3
|
-
* (`ends_at`), one moment for every shopper, and hidden with its label once it has passed, as an
|
|
4
|
-
* ended no longer hurries anyone;
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* (`ends_at`), one moment for every shopper, and hidden with its label once it has passed, as an
|
|
4
|
+
* offer that has ended no longer hurries anyone; to the form's next daily cut-off (`daily: true`,
|
|
5
|
+
* the moments the settings' `window.daily` lists), moving on to the next as each passes, and hidden
|
|
6
|
+
* while none is ahead; else `minutes` from when it is first shown, such as how long a claimed code is
|
|
7
|
+
* held. A preview shows a deadline not yet set, or passed, or a cut-off with none ahead, at zero. It
|
|
8
|
+
* comes with the answers chunk (./answers.js), loaded when a step shows one.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -11,23 +13,31 @@
|
|
|
11
13
|
* hidden, and hiding it again once a deadline has passed.
|
|
12
14
|
*
|
|
13
15
|
* @param {HTMLElement} box The block: its label, if any, then its `role=timer` paragraph.
|
|
14
|
-
* @param {{ ends_at?: string, minutes?: number }} block
|
|
16
|
+
* @param {{ ends_at?: string, minutes?: number, daily?: true }} block
|
|
15
17
|
* @param {boolean} preview
|
|
18
|
+
* @param {number[]} [cutoffs] The form's daily cut-offs (`window.daily.next`), which a `daily` block counts to.
|
|
16
19
|
* @returns {() => void} Stops it, for when the step changes.
|
|
17
20
|
*/
|
|
18
|
-
export function countdown(box, block, preview) {
|
|
21
|
+
export function countdown(box, block, preview, cutoffs) {
|
|
19
22
|
const node = box.lastChild;
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// The next cut-off, read again as each passes; none on a form without, so it is hidden.
|
|
24
|
+
const next =
|
|
25
|
+
block.daily && (() => cutoffs?.find?.((t) => t > Date.now()) || 0);
|
|
26
|
+
const deadline = next || 'ends_at' in block;
|
|
27
|
+
let ends = next
|
|
28
|
+
? next()
|
|
29
|
+
: 'ends_at' in block
|
|
30
|
+
? Date.parse(block.ends_at)
|
|
31
|
+
: Date.now() + Math.max(0, Number(block.minutes) || 0) * 60_000;
|
|
24
32
|
const tick = () => {
|
|
33
|
+
if (next && ends <= Date.now()) ends = next();
|
|
25
34
|
const left = Math.max(0, ends - Date.now()) || 0;
|
|
26
35
|
const days = Math.floor(left / 864e5);
|
|
27
36
|
// mm:ss; hh:mm:ss from an hour; and the days before them from a day.
|
|
28
37
|
node.textContent = `${days ? `${days}d ` : ''}${new Date(left).toISOString().slice(left < 36e5 ? 14 : 11, 19)}`;
|
|
29
38
|
box.hidden = !left && deadline && !preview;
|
|
30
|
-
|
|
39
|
+
// It stops at zero, but for a daily cut-off with another still to come.
|
|
40
|
+
if (!left && !(next && ends)) clearInterval(id);
|
|
31
41
|
};
|
|
32
42
|
const id = setInterval(tick, 1000);
|
|
33
43
|
tick();
|
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';
|
|
@@ -28,11 +29,15 @@ import {
|
|
|
28
29
|
ASKED,
|
|
29
30
|
blockers,
|
|
30
31
|
byPriority,
|
|
32
|
+
CODE_IN_BASKET,
|
|
31
33
|
deviceOf,
|
|
32
34
|
INTERRUPTS,
|
|
35
|
+
OUTSIDE_WINDOW,
|
|
33
36
|
OVERLAY,
|
|
37
|
+
opensIn,
|
|
34
38
|
readCreative,
|
|
35
39
|
readForm,
|
|
40
|
+
UNSAID,
|
|
36
41
|
} from './rules.js';
|
|
37
42
|
import { watch } from './triggers.js';
|
|
38
43
|
import { loadValues } from './values-loader.js';
|
|
@@ -48,6 +53,9 @@ const PENDING = Symbol.for(
|
|
|
48
53
|
const LISTENERS = Symbol.for(
|
|
49
54
|
'@thg-agentic/hyper-personalisation-web.onsite.listeners',
|
|
50
55
|
);
|
|
56
|
+
const PAGE_SAID = Symbol.for(
|
|
57
|
+
'@thg-agentic/hyper-personalisation-web.onsite.page-said',
|
|
58
|
+
);
|
|
51
59
|
const SUPPRESSED = Symbol.for(
|
|
52
60
|
'@thg-agentic/hyper-personalisation-web.onsite.suppressed',
|
|
53
61
|
);
|
|
@@ -226,6 +234,20 @@ export function startOnsite(client, options = {}) {
|
|
|
226
234
|
};
|
|
227
235
|
/** Forms whose trigger fired on an empty basket: they try again when the basket changes. */
|
|
228
236
|
const awaitingBasket = new Set();
|
|
237
|
+
/**
|
|
238
|
+
* Forms a code in the basket held back, and whether their trigger had fired: once it has none, a
|
|
239
|
+
* fired one competes to show at once and any other is started again.
|
|
240
|
+
*/
|
|
241
|
+
const awaitingNoCode = new Map();
|
|
242
|
+
const basketHasCode = () => access.basketCodes?.()?.length > 0;
|
|
243
|
+
const holdForCode = (form, reasons, fired) => {
|
|
244
|
+
if (reasons.includes(CODE_IN_BASKET)) awaitingNoCode.set(form, fired);
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Teasers' tabs kept from an earlier page, held back only until the page says what it is about
|
|
248
|
+
* (its facts, its product), which it may do after onsite starts: they try again when it does.
|
|
249
|
+
*/
|
|
250
|
+
const awaitingPage = new Set();
|
|
229
251
|
/** Covering forms whose trigger fired before the consent banner was answered. */
|
|
230
252
|
const awaitingConsent = new Map();
|
|
231
253
|
|
|
@@ -289,9 +311,14 @@ export function startOnsite(client, options = {}) {
|
|
|
289
311
|
path: location.pathname,
|
|
290
312
|
device,
|
|
291
313
|
campaign: campaign(),
|
|
314
|
+
// What the page says, and the product it is about: both held in memory, cheap to read.
|
|
315
|
+
facts: pageFacts(),
|
|
316
|
+
product: access.pageProduct?.(),
|
|
292
317
|
now: Date.now(),
|
|
293
318
|
seen: counters.seen(form.slot),
|
|
294
319
|
subscribed: counters.subscribed() || options.subscribed?.() === true,
|
|
320
|
+
// Read only for a form that asks: it reads the visit's kept signals.
|
|
321
|
+
basketHasCode: form.exclude_code_applied === true && basketHasCode(),
|
|
295
322
|
personalisation: access.personalisation(),
|
|
296
323
|
preview: Boolean(access.preview),
|
|
297
324
|
audiences: sessionAudiences(),
|
|
@@ -357,8 +384,31 @@ export function startOnsite(client, options = {}) {
|
|
|
357
384
|
asked,
|
|
358
385
|
});
|
|
359
386
|
if (reasons.length) {
|
|
387
|
+
holdForCode(form, reasons, !kept);
|
|
388
|
+
if (kept && reasons.every((r) => UNSAID.includes(r)))
|
|
389
|
+
awaitingPage.add(form);
|
|
390
|
+
// A trigger that fired before the form's daily window opened is watched again once it
|
|
391
|
+
// opens, so the shopper's next exit (or the page's entry) still counts.
|
|
392
|
+
if (
|
|
393
|
+
reasons.length === 1 &&
|
|
394
|
+
reasons[0] === OUTSIDE_WINDOW &&
|
|
395
|
+
!asked &&
|
|
396
|
+
!kept &&
|
|
397
|
+
!stopped
|
|
398
|
+
) {
|
|
399
|
+
const inline =
|
|
400
|
+
form.trigger.type === 'inline' || form.format === 'inline';
|
|
401
|
+
// In place of any watcher it still has (the site opened it by hand), never beside it.
|
|
402
|
+
unwatch(form);
|
|
403
|
+
const later = setTimeout(
|
|
404
|
+
() => (inline ? show(form, 'inline') : arm(form)),
|
|
405
|
+
opensIn(form, Date.now()),
|
|
406
|
+
);
|
|
407
|
+
stops.set(form.slot, () => clearTimeout(later));
|
|
408
|
+
}
|
|
360
409
|
return note(form, 'not eligible', 'not-eligible', reasons);
|
|
361
410
|
}
|
|
411
|
+
awaitingPage.delete(form);
|
|
362
412
|
// The gap first: it holds for the whole session, so it is the reason worth reading.
|
|
363
413
|
const interrupts = INTERRUPTS(form.format) && !kept;
|
|
364
414
|
const wait = interrupts && !asked && gapHolds();
|
|
@@ -442,11 +492,17 @@ export function startOnsite(client, options = {}) {
|
|
|
442
492
|
api.decide(form.slot, counters.pin(form.slot), {
|
|
443
493
|
visitAudiences: claimed,
|
|
444
494
|
visitCampaign: form.arrived_from.length ? campaign() : null,
|
|
445
|
-
// Only for a form whose products are filled from it
|
|
495
|
+
// Only for a form whose products are filled from it, or limited to products (then only
|
|
496
|
+
// the page's product); never kept or logged by the server.
|
|
446
497
|
visitContext:
|
|
447
498
|
form.visit_context === true
|
|
448
499
|
? (access.visitContext?.() ?? null)
|
|
449
|
-
:
|
|
500
|
+
: form.products
|
|
501
|
+
? { product_id: access.pageProduct?.() }
|
|
502
|
+
: null,
|
|
503
|
+
pageFacts: form.page_facts.length
|
|
504
|
+
? factsFor(form.page_facts, pageFacts())
|
|
505
|
+
: null,
|
|
450
506
|
timeoutMs: deadline - Date.now(),
|
|
451
507
|
}),
|
|
452
508
|
deadline,
|
|
@@ -489,6 +545,7 @@ export function startOnsite(client, options = {}) {
|
|
|
489
545
|
]);
|
|
490
546
|
}
|
|
491
547
|
awaitingBasket.delete(form);
|
|
548
|
+
awaitingNoCode.delete(form);
|
|
492
549
|
if (!creative) {
|
|
493
550
|
const held = decision?.holdout === true;
|
|
494
551
|
// A held-out visitor whose trigger fired is exposed to the default, as a treated one is,
|
|
@@ -645,16 +702,18 @@ export function startOnsite(client, options = {}) {
|
|
|
645
702
|
if (missed) say(...missed);
|
|
646
703
|
}
|
|
647
704
|
|
|
648
|
-
|
|
705
|
+
/** A form as the page starts it, and again once a code that held it back leaves the basket. */
|
|
706
|
+
function start(form) {
|
|
649
707
|
// A teaser's tab still out from an earlier page comes back whatever its caps and trigger.
|
|
650
|
-
if (form.format === 'teaser' && counters.tabOut(form.slot))
|
|
651
|
-
show(form, 'tab kept from an earlier page', true);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
708
|
+
if (form.format === 'teaser' && counters.tabOut(form.slot))
|
|
709
|
+
return show(form, 'tab kept from an earlier page', true);
|
|
710
|
+
// What the page says, which product it is about, and whether its daily window is open may
|
|
711
|
+
// all change while the shopper is on it, so a form waits on its trigger for them; they are
|
|
712
|
+
// judged when it fires.
|
|
713
|
+
const reasons = blockers(form, { ...env(form), later: true });
|
|
655
714
|
if (reasons.length) {
|
|
656
|
-
|
|
657
|
-
|
|
715
|
+
holdForCode(form, reasons, false);
|
|
716
|
+
return note(form, 'not eligible', 'not-eligible', reasons);
|
|
658
717
|
}
|
|
659
718
|
const type = form.trigger.type;
|
|
660
719
|
if (type === 'manual')
|
|
@@ -663,6 +722,7 @@ export function startOnsite(client, options = {}) {
|
|
|
663
722
|
show(form, 'inline');
|
|
664
723
|
else arm(form);
|
|
665
724
|
}
|
|
725
|
+
for (const form of forms) start(form);
|
|
666
726
|
|
|
667
727
|
/** Watch a form's trigger; when it fires, the form competes to show. */
|
|
668
728
|
function arm(form) {
|
|
@@ -692,7 +752,32 @@ export function startOnsite(client, options = {}) {
|
|
|
692
752
|
asked ? fire(form, 'manual', asked) : arm(form);
|
|
693
753
|
});
|
|
694
754
|
|
|
755
|
+
const heard = () => {
|
|
756
|
+
const waiting = [...awaitingPage];
|
|
757
|
+
awaitingPage.clear();
|
|
758
|
+
// After any attempt still settling: it judged the page before the page said so.
|
|
759
|
+
for (const form of waiting)
|
|
760
|
+
Promise.resolve(opening.get(form.slot)?.done).then(
|
|
761
|
+
() =>
|
|
762
|
+
stopped ||
|
|
763
|
+
mounted.has(form.slot) ||
|
|
764
|
+
show(form, 'tab kept from an earlier page', true),
|
|
765
|
+
);
|
|
766
|
+
};
|
|
767
|
+
(window[PAGE_SAID] ??= new Set()).add(heard);
|
|
768
|
+
|
|
695
769
|
const stopBasket = access.onBasket?.(() => {
|
|
770
|
+
if (awaitingNoCode.size && !basketHasCode()) {
|
|
771
|
+
// A task later, so an attempt that held one back has finished being opened.
|
|
772
|
+
for (const [form, fired] of awaitingNoCode)
|
|
773
|
+
setTimeout(
|
|
774
|
+
() =>
|
|
775
|
+
stopped ||
|
|
776
|
+
mounted.has(form.slot) ||
|
|
777
|
+
(fired ? fire(form, 'the basket changed') : start(form)),
|
|
778
|
+
);
|
|
779
|
+
awaitingNoCode.clear();
|
|
780
|
+
}
|
|
696
781
|
if (!((access.basketValue?.() ?? 0) > 0)) return;
|
|
697
782
|
// Out of the set before the decision is asked, so a second update cannot show them twice;
|
|
698
783
|
// covering ones compete on priority as any trigger does.
|
|
@@ -743,7 +828,10 @@ export function startOnsite(client, options = {}) {
|
|
|
743
828
|
stopBasket?.();
|
|
744
829
|
stopConsent?.();
|
|
745
830
|
awaitingBasket.clear();
|
|
831
|
+
awaitingNoCode.clear();
|
|
746
832
|
awaitingConsent.clear();
|
|
833
|
+
awaitingPage.clear();
|
|
834
|
+
window[PAGE_SAID]?.delete(heard);
|
|
747
835
|
if (window[CONTROLLER] === controller) delete window[CONTROLLER];
|
|
748
836
|
},
|
|
749
837
|
});
|
|
@@ -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,23 @@ 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
|
+
/** Why a form that hides from a basket with a discount code in it is not shown. */
|
|
152
|
+
export const CODE_IN_BASKET = 'code in basket';
|
|
153
|
+
|
|
154
|
+
/** A form held back only until its daily window opens; `opensIn` says how long that is. */
|
|
155
|
+
export const OUTSIDE_WINDOW = 'outside its daily window';
|
|
156
|
+
export const opensIn = (form, now) => {
|
|
157
|
+
const { daily } = form.window;
|
|
158
|
+
return daily.next.find((t) => t > now) - daily.within_hours * 36e5 - now;
|
|
159
|
+
};
|
|
160
|
+
|
|
139
161
|
/**
|
|
140
|
-
* Every reason `form` may not show now, empty when it may
|
|
141
|
-
*
|
|
142
|
-
* `
|
|
162
|
+
* Every reason `form` may not show now, empty when it may: its pages, devices, campaign, page
|
|
163
|
+
* facts and products, dates and daily cut-off, audiences and counters. `env` is the page:
|
|
164
|
+
* `{ path, device, campaign, facts, product, now, seen, subscribed, basketHasCode, personalisation, preview,
|
|
165
|
+
* audiences }`, where `campaign` is the visit's (`visitCampaign`), `facts` what the page said
|
|
166
|
+
* about itself (`hp.onsite.facts`), `product` the id of the product the page is about (its last
|
|
167
|
+
* Product Viewed), `seen` the slot's counters and `audiences` the
|
|
143
168
|
* session audiences the host gave, or null. The campaign is context about this visit, as the page
|
|
144
169
|
* is, so it needs no personalisation consent. A form limited to audiences needs personalisation
|
|
145
170
|
* consent; whether the visitor is in one is the server's to decide (`POST /v1/decisions`), unless
|
|
@@ -152,9 +177,26 @@ export function blockers(form, env) {
|
|
|
152
177
|
reasons.push(`not on ${env.device}`);
|
|
153
178
|
if (!arrivedFrom(form.arrived_from, env.campaign))
|
|
154
179
|
reasons.push('not from its campaign');
|
|
180
|
+
// A preview shows the version asked for on any page, as the server answers it.
|
|
181
|
+
if (!env.later && !env.preview) {
|
|
182
|
+
if (!pageFactsHold(form.page_facts, env.facts)) reasons.push(UNSAID[0]);
|
|
183
|
+
// Which products: the server's to decide; a page about none can never be one of them.
|
|
184
|
+
if (form.products && !env.product) reasons.push(UNSAID[1]);
|
|
185
|
+
}
|
|
155
186
|
const starts = at(form.window.starts_at);
|
|
156
187
|
const ends = at(form.window.ends_at);
|
|
157
188
|
if (env.now < starts || env.now >= ends) reasons.push('outside its dates');
|
|
189
|
+
// Its daily cut-off is targeting too: checked before any decision, so a held-out visitor is
|
|
190
|
+
// filtered as a shown one is. Its window is judged when the trigger fires (`later` at start), as
|
|
191
|
+
// it may open while the shopper is on the page; with no cut-off ahead it never can. The server
|
|
192
|
+
// lists the moments; a trader previewing sees it at any hour.
|
|
193
|
+
const { daily } = form.window;
|
|
194
|
+
if (daily && !env.preview) {
|
|
195
|
+
const left = daily.next?.find?.((t) => t > env.now) - env.now;
|
|
196
|
+
if (!(left > 0)) reasons.push('no cut-off ahead');
|
|
197
|
+
else if (!env.later && left > daily.within_hours * 36e5)
|
|
198
|
+
reasons.push(OUTSIDE_WINDOW);
|
|
199
|
+
}
|
|
158
200
|
// A trader previewing a limited form sees it, as the server answers a preview.
|
|
159
201
|
if (form.audiences.length && !env.preview) {
|
|
160
202
|
if (!env.personalisation)
|
|
@@ -172,6 +214,8 @@ export function blockers(form, env) {
|
|
|
172
214
|
if (seen.submitted) reasons.push('already submitted');
|
|
173
215
|
if (form.exclude_subscribed === true && env.subscribed)
|
|
174
216
|
reasons.push('already subscribed');
|
|
217
|
+
if (form.exclude_code_applied === true && env.basketHasCode)
|
|
218
|
+
reasons.push(CODE_IN_BASKET);
|
|
175
219
|
if (caps.per_session !== null && seen.session >= caps.per_session)
|
|
176
220
|
reasons.push('per_session cap');
|
|
177
221
|
if (caps.lifetime !== null && seen.lifetime >= caps.lifetime)
|
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/segment.js
CHANGED
|
@@ -329,7 +329,8 @@ export function cdpPlugins(options = {}) {
|
|
|
329
329
|
context.identity_trust = event.anonymousId ? 'device' : 'none';
|
|
330
330
|
event.context = context;
|
|
331
331
|
|
|
332
|
-
if (event.properties)
|
|
332
|
+
if (event.properties)
|
|
333
|
+
event.properties = pruneProperties(event.properties, event.event);
|
|
333
334
|
return ctx;
|
|
334
335
|
}
|
|
335
336
|
|