@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.
@@ -216,14 +216,17 @@ function style(root, make) {
216
216
  root.append(node);
217
217
  }
218
218
 
219
- /** A countdown block into the renderer's `box`: its label, then its timer, ticked (./countdown.js). */
220
- export function countdown(box, block, preview, make) {
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
- * Bounded by `timeoutMs`, at most DECISION_TIMEOUT_MS.
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. */
@@ -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 offer that has
4
- * ended no longer hurries anyone; else `minutes` from when it is first shown, such as how long a
5
- * claimed code is held. A preview shows a deadline not yet set, or passed, at zero. It comes with
6
- * the answers chunk (./answers.js), loaded when a step shows one.
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
- const deadline = 'ends_at' in block;
21
- const ends = deadline
22
- ? Date.parse(block.ends_at)
23
- : Date.now() + Math.max(0, Number(block.minutes) || 0) * 60_000;
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
- if (!left) clearInterval(id);
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();
@@ -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; never kept or logged by the server.
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
- : null,
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
- for (const form of forms) {
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
- continue;
653
- }
654
- const reasons = blockers(form, env(form));
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
- note(form, 'not eligible', 'not-eligible', reasons);
657
- continue;
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
+ );
@@ -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({
@@ -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 deadline passes. Hidden until then, so
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(m.countdown(box, block, preview, el)),
777
+ state.timers.push(
778
+ m.countdown(box, block, preview, el, form.window.daily?.next),
779
+ ),
777
780
  );
778
781
  return box;
779
782
  }
@@ -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
- /** A rule no visit meets: what an `arrived_from` this SDK cannot read becomes, so it fails closed. */
45
- const NEVER = { field: null, match: null, values: [] };
46
- const arrivedRules = (raw) =>
47
- raw == null ? [] : Array.isArray(raw) && raw.every(isObject) ? raw : [NEVER];
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: arrivedRules(raw.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. `env` is the page:
141
- * `{ path, device, campaign, now, seen, subscribed, personalisation, preview, audiences }`, where
142
- * `campaign` is the visit's (`visitCampaign`), `seen` the slot's counters and `audiences` the
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)
@@ -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) event.properties = pruneProperties(event.properties);
332
+ if (event.properties)
333
+ event.properties = pruneProperties(event.properties, event.event);
333
334
  return ctx;
334
335
  }
335
336