@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.
@@ -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 a creative shows a products block on any step. */
106
- const showsProducts = (creative) =>
107
- Array.isArray(creative?.steps) &&
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; never kept or logged by the server.
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
- : null,
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 are drawn by their own chunk: without it the form would open on an empty block.
525
- if (showsProducts(creative)) {
526
- cards ??= loadProducts().catch(() => null);
527
- if (!(await within(cards, deadline, null)))
528
- return drop(form, 'not shown', 'products-failed', [
529
- 'its products did not load',
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
- const reasons = blockers(form, env(form));
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
+ );
@@ -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 }} [options]
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,
@@ -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
- try {
46
- const format = new Intl.NumberFormat(lang || undefined, {
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
  /**
@@ -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 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
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(m.countdown(box, block, preview, el)),
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(step) {
1006
- const heading = step.blocks.find((b) => b?.t === 'heading');
1007
- const open = step.blocks.find(
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
- if (asTab && l.template !== 'image-only') body.append(tab(step));
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 step.blocks.entries()) {
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)
@@ -5,7 +5,18 @@
5
5
  * RichText = string | Array<string | { b: RichText } | { i: RichText } | { a: RichText, href }>
6
6
  */
7
7
 
8
- const MAX_DEPTH = 6;
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) {
@@ -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,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. `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
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');