@thg-agentic/hyper-personalisation-web 3.32.0 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  * startOnsite(hp); // the client bootstrap resolved to, or pass `onsite: true` to bootstrap
7
7
  *
8
8
  * The browser judges the trigger; the server decides the rest. Each live form's trigger is watched
9
- * here, with its pages, devices, dates and caps. When one fires, `POST /v1/decisions` is asked for
9
+ * here, with its pages, devices, dates, daily cut-off and caps. When one fires, `POST /v1/decisions` is asked for
10
10
  * that one slot: a held-out visitor, or no decision, shows nothing. One overlay per page view, the
11
11
  * highest priority among those that fire within 50 ms; one that cannot show passes to the next, but
12
12
  * a held-out visitor sees none, and once one has shown, closing it lets no other in; with the
@@ -21,6 +21,7 @@ import { loadAnswers } from './answers-loader.js';
21
21
  import { createApi } from './api.js';
22
22
  import { loadAudiences } from './audiences-loader.js';
23
23
  import { createCounters } from './counters.js';
24
+ import { factsFor, pageFacts } from './page-facts.js';
24
25
  import { loadProducts } from './products-loader.js';
25
26
  import { loadRender } from './render-loader.js';
26
27
  import { holdsValue } from './rich-text.js';
@@ -30,9 +31,12 @@ import {
30
31
  byPriority,
31
32
  deviceOf,
32
33
  INTERRUPTS,
34
+ OUTSIDE_WINDOW,
33
35
  OVERLAY,
36
+ opensIn,
34
37
  readCreative,
35
38
  readForm,
39
+ UNSAID,
36
40
  } from './rules.js';
37
41
  import { watch } from './triggers.js';
38
42
  import { loadValues } from './values-loader.js';
@@ -48,6 +52,9 @@ const PENDING = Symbol.for(
48
52
  const LISTENERS = Symbol.for(
49
53
  '@thg-agentic/hyper-personalisation-web.onsite.listeners',
50
54
  );
55
+ const PAGE_SAID = Symbol.for(
56
+ '@thg-agentic/hyper-personalisation-web.onsite.page-said',
57
+ );
51
58
  const SUPPRESSED = Symbol.for(
52
59
  '@thg-agentic/hyper-personalisation-web.onsite.suppressed',
53
60
  );
@@ -226,6 +233,11 @@ export function startOnsite(client, options = {}) {
226
233
  };
227
234
  /** Forms whose trigger fired on an empty basket: they try again when the basket changes. */
228
235
  const awaitingBasket = new Set();
236
+ /**
237
+ * Teasers' tabs kept from an earlier page, held back only until the page says what it is about
238
+ * (its facts, its product), which it may do after onsite starts: they try again when it does.
239
+ */
240
+ const awaitingPage = new Set();
229
241
  /** Covering forms whose trigger fired before the consent banner was answered. */
230
242
  const awaitingConsent = new Map();
231
243
 
@@ -289,6 +301,9 @@ export function startOnsite(client, options = {}) {
289
301
  path: location.pathname,
290
302
  device,
291
303
  campaign: campaign(),
304
+ // What the page says, and the product it is about: both held in memory, cheap to read.
305
+ facts: pageFacts(),
306
+ product: access.pageProduct?.(),
292
307
  now: Date.now(),
293
308
  seen: counters.seen(form.slot),
294
309
  subscribed: counters.subscribed() || options.subscribed?.() === true,
@@ -357,8 +372,30 @@ export function startOnsite(client, options = {}) {
357
372
  asked,
358
373
  });
359
374
  if (reasons.length) {
375
+ if (kept && reasons.every((r) => UNSAID.includes(r)))
376
+ awaitingPage.add(form);
377
+ // A trigger that fired before the form's daily window opened is watched again once it
378
+ // opens, so the shopper's next exit (or the page's entry) still counts.
379
+ if (
380
+ reasons.length === 1 &&
381
+ reasons[0] === OUTSIDE_WINDOW &&
382
+ !asked &&
383
+ !kept &&
384
+ !stopped
385
+ ) {
386
+ const inline =
387
+ form.trigger.type === 'inline' || form.format === 'inline';
388
+ // In place of any watcher it still has (the site opened it by hand), never beside it.
389
+ unwatch(form);
390
+ const later = setTimeout(
391
+ () => (inline ? show(form, 'inline') : arm(form)),
392
+ opensIn(form, Date.now()),
393
+ );
394
+ stops.set(form.slot, () => clearTimeout(later));
395
+ }
360
396
  return note(form, 'not eligible', 'not-eligible', reasons);
361
397
  }
398
+ awaitingPage.delete(form);
362
399
  // The gap first: it holds for the whole session, so it is the reason worth reading.
363
400
  const interrupts = INTERRUPTS(form.format) && !kept;
364
401
  const wait = interrupts && !asked && gapHolds();
@@ -442,11 +479,17 @@ export function startOnsite(client, options = {}) {
442
479
  api.decide(form.slot, counters.pin(form.slot), {
443
480
  visitAudiences: claimed,
444
481
  visitCampaign: form.arrived_from.length ? campaign() : null,
445
- // Only for a form whose products are filled from it; 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.
446
484
  visitContext:
447
485
  form.visit_context === true
448
486
  ? (access.visitContext?.() ?? null)
449
- : 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,
450
493
  timeoutMs: deadline - Date.now(),
451
494
  }),
452
495
  deadline,
@@ -651,7 +694,10 @@ export function startOnsite(client, options = {}) {
651
694
  show(form, 'tab kept from an earlier page', true);
652
695
  continue;
653
696
  }
654
- 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 });
655
701
  if (reasons.length) {
656
702
  note(form, 'not eligible', 'not-eligible', reasons);
657
703
  continue;
@@ -692,6 +738,20 @@ export function startOnsite(client, options = {}) {
692
738
  asked ? fire(form, 'manual', asked) : arm(form);
693
739
  });
694
740
 
741
+ const heard = () => {
742
+ const waiting = [...awaitingPage];
743
+ awaitingPage.clear();
744
+ // After any attempt still settling: it judged the page before the page said so.
745
+ for (const form of waiting)
746
+ Promise.resolve(opening.get(form.slot)?.done).then(
747
+ () =>
748
+ stopped ||
749
+ mounted.has(form.slot) ||
750
+ show(form, 'tab kept from an earlier page', true),
751
+ );
752
+ };
753
+ (window[PAGE_SAID] ??= new Set()).add(heard);
754
+
695
755
  const stopBasket = access.onBasket?.(() => {
696
756
  if (!((access.basketValue?.() ?? 0) > 0)) return;
697
757
  // Out of the set before the decision is asked, so a second update cannot show them twice;
@@ -744,6 +804,8 @@ export function startOnsite(client, options = {}) {
744
804
  stopConsent?.();
745
805
  awaitingBasket.clear();
746
806
  awaitingConsent.clear();
807
+ awaitingPage.clear();
808
+ window[PAGE_SAID]?.delete(heard);
747
809
  if (window[CONTROLLER] === controller) delete window[CONTROLLER];
748
810
  },
749
811
  });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Page facts (#1164): what the page says about itself, given with `hp.onsite.facts({ … })`, such as
3
+ * whether the product on it can be delivered tomorrow. Kept in the page's memory for the path it was
4
+ * given on, so a single-page app's next page starts with none; an anchor or a variant's query on the
5
+ * same path keeps them. A form's
6
+ * `page_facts` rules are judged here and again by the public API, which reads the facts the request carries (only those the rules
7
+ * read) and drops any that are not a fact: both run spec/onsite/examples/page-facts.cases.json.
8
+ */
9
+
10
+ /** Where `hp.onsite.facts` (../onsite-facade.js) keeps them: the page's, not this chunk's. */
11
+ const PAGE_FACTS = Symbol.for(
12
+ '@thg-agentic/hyper-personalisation-web.onsite.page-facts',
13
+ );
14
+
15
+ /** The facts this page gave, on the path it is on now; `{}` when none. */
16
+ export function pageFacts(win = globalThis.window) {
17
+ const held = win?.[PAGE_FACTS];
18
+ return held?.path === win?.location?.pathname ? held.facts : {};
19
+ }
20
+
21
+ /** A fact as the public API reads it: words up to 100 characters. */
22
+ const read = (v) => (typeof v === 'string' ? v.slice(0, 100) : v);
23
+
24
+ const norm = (v) => (typeof v === 'string' ? v.trim().toLowerCase() : v);
25
+
26
+ /**
27
+ * Whether `facts` meets every rule. A fact the page did not give, or gave as another kind, meets no
28
+ * rule on it, `is_not` included; a rule this SDK cannot read meets none. No rules is any page. The
29
+ * control plane's `pageFactsHold` is the same.
30
+ *
31
+ * @param {Array<{ fact: string, op: string, values: unknown[] }>} rules
32
+ * @param {Record<string, unknown> | null} facts
33
+ */
34
+ export const pageFactsHold = (rules, facts) =>
35
+ rules.every((rule) => {
36
+ // As the public API reads them, and only finite numbers.
37
+ const have = read(facts?.[rule.fact]);
38
+ const values = Array.isArray(rule.values) ? rule.values : [];
39
+ if (
40
+ have == null ||
41
+ typeof have !== typeof values[0] ||
42
+ (typeof have === 'number' && !Number.isFinite(have))
43
+ )
44
+ return false;
45
+ const any = values.some((want) => norm(want) === norm(have));
46
+ return rule.op === 'is'
47
+ ? any
48
+ : rule.op === 'is_not'
49
+ ? !any
50
+ : rule.op === 'at_least'
51
+ ? have >= values[0]
52
+ : rule.op === 'at_most' && have <= values[0];
53
+ });
54
+
55
+ /** Only the facts `rules` read, for the decision request: nothing else the page gave leaves it. */
56
+ export const factsFor = (rules, facts) =>
57
+ Object.fromEntries(
58
+ rules
59
+ .filter((r) => r.fact in facts)
60
+ .map((r) => [r.fact, read(facts[r.fact])]),
61
+ );
@@ -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,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)
@@ -19,11 +19,27 @@ const SUPPRESSED = Symbol.for(
19
19
  const PAGE_VALUES = Symbol.for(
20
20
  '@thg-agentic/hyper-personalisation-web.onsite.page-values',
21
21
  );
22
+ const PAGE_FACTS = Symbol.for(
23
+ '@thg-agentic/hyper-personalisation-web.onsite.page-facts',
24
+ );
25
+ /** The runtime's listeners for what the page says: its facts, or the product it is about. */
26
+ const PAGE_SAID = Symbol.for(
27
+ '@thg-agentic/hyper-personalisation-web.onsite.page-said',
28
+ );
22
29
  const EVENTS = ['shown', 'submitted', 'apply', 'closed', 'held', 'nothing'];
23
30
  const MAX_PENDING = 10;
24
31
 
25
32
  const page = () => (typeof window === 'undefined' ? null : window);
26
33
 
34
+ /** The page said what it is about (facts, or a Product Viewed): the runtime judges again what waited. */
35
+ export function pageSaid() {
36
+ try {
37
+ for (const heard of page()?.[PAGE_SAID] ?? []) heard();
38
+ } catch {
39
+ /* never into the page */
40
+ }
41
+ }
42
+
27
43
  /** No onsite runtime will start on this page: every `open` waiting for one resolves `false`. */
28
44
  export function dropPendingOpens() {
29
45
  try {
@@ -111,6 +127,27 @@ export const onsite = Object.freeze({
111
127
  /* never into the page */
112
128
  }
113
129
  },
130
+ /**
131
+ * What this page says about itself, for forms limited by page facts:
132
+ * `{ next_day_eligible: true }`, each value true or false, a number or words. Kept in memory for
133
+ * this page's path only (a single-page app's next page starts with none); a form's request sends
134
+ * only the facts its rules read, never logged.
135
+ * Each call replaces the last (call it again when the shopper picks another size), and
136
+ * `facts({})` forgets.
137
+ */
138
+ facts(given) {
139
+ try {
140
+ const w = page();
141
+ if (w)
142
+ w[PAGE_FACTS] = {
143
+ path: w.location?.pathname,
144
+ facts: given && typeof given === 'object' ? { ...given } : {},
145
+ };
146
+ } catch {
147
+ /* never into the page */
148
+ }
149
+ pageSaid();
150
+ },
114
151
  /**
115
152
  * Each form's state, as `debug`, once the page knows whether onsite forms run on it: when the
116
153
  * runtime has started, or `[]` once none will (not opted in, no live form, the chunk failed).
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.32.0';
1
+ export const VERSION = '3.34.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -34,6 +34,8 @@ export interface PreviewOptions {
34
34
  prizes?: Array<{ key: string; label: string }>;
35
35
  /** The basket a progress block is drawn against; 32 when left out. */
36
36
  basketValue?: number;
37
+ /** The form's daily cut-off as the settings carry it (`window.daily`), which a `daily` countdown counts to. */
38
+ daily?: DailyCutoff;
37
39
  /**
38
40
  * A returning shopper's values for the text's shopper values (`answers` by question, as option
39
41
  * values); null or left out, every value shows its fallback, as a new visitor sees it.
@@ -83,3 +85,14 @@ export declare function formatValue(
83
85
  now?: number;
84
86
  },
85
87
  ): string | null;
88
+
89
+ /**
90
+ * A form's daily cut-off as the settings carry it (`window.daily`): the moments it falls, which the
91
+ * server works out in the shop's time zone, and a `daily` countdown counts to the first ahead.
92
+ */
93
+ export interface DailyCutoff {
94
+ /** The cut-offs, epoch milliseconds, earliest first, up to 8 days on from when they were listed. */
95
+ next: readonly number[];
96
+ /** The form shows only while the next cut-off is at most this many hours away. */
97
+ within_hours?: number;
98
+ }
package/types/onsite.d.ts CHANGED
@@ -31,6 +31,16 @@ export interface OnsiteValueNode {
31
31
  };
32
32
  }
33
33
 
34
+ /** What a page says about itself, for forms limited by page facts (3.34). */
35
+ export type OnsitePageFacts = Record<string, boolean | number | string>;
36
+
37
+ /** One rule on a page fact: `is` / `is_not` one of `values`, or `at_least` / `at_most` a number. */
38
+ export interface OnsitePageFactRule {
39
+ fact: string;
40
+ op: 'is' | 'is_not' | 'at_least' | 'at_most';
41
+ values: Array<boolean | number | string>;
42
+ }
43
+
34
44
  /** What the page knows of the shopper for a form's text. Never sent, stored or logged. */
35
45
  export interface OnsitePageValues {
36
46
  first_name?: string;
@@ -85,11 +95,26 @@ export interface OnsiteForm {
85
95
  match: 'is' | 'contains';
86
96
  values: string[];
87
97
  }>;
98
+ /**
99
+ * Shown only where the page says so (3.34): rules on the facts it gave with `hp.onsite.facts`.
100
+ * Every rule must hold; a fact the page did not give meets none.
101
+ */
102
+ page_facts?: OnsitePageFactRule[];
103
+ /** Shown only on the pages of chosen products (3.34): the server checks the page's product. */
104
+ products?: true;
88
105
  /** Shown only with personalisation consent, and only to members as the server decides. */
89
106
  audiences?: string[];
90
107
  exclude_subscribed?: boolean;
91
108
  caps?: { per_session?: number; lifetime?: number; after_close_days?: number };
92
- window?: { starts_at?: string; ends_at?: string };
109
+ /**
110
+ * Its dates and its daily cut-off: with one, it shows only while one of its listed cut-offs is
111
+ * still ahead and, with `within_hours`, only that close to it (3.33).
112
+ */
113
+ window?: {
114
+ starts_at?: string;
115
+ ends_at?: string;
116
+ daily?: import('./onsite-preview').DailyCutoff;
117
+ };
93
118
  /**
94
119
  * The form's language, BCP 47 (`en-IE`): its phone field starts on this region's country code,
95
120
  * and a birthday's day and month follow its order and month names. Absent: the page's `lang`.
@@ -328,6 +353,14 @@ export interface OnsiteFacade {
328
353
  * memory only, never sent, stored or logged. Each call replaces the last; `values({})` forgets.
329
354
  */
330
355
  values(given: OnsitePageValues): void;
356
+ /**
357
+ * What this page says about itself (3.34), such as `{ next_day_eligible: true }`: kept in memory
358
+ * for this page's path only; a form's decision request sends only the facts its rules read,
359
+ * never logged. Each call replaces the last; call it again when the shopper picks another size.
360
+ * `facts({})` forgets. Names are lower case, digits and underscores; values true or false, a
361
+ * number or words; at most 20.
362
+ */
363
+ facts(given: OnsitePageFacts): void;
331
364
  /** Each form's state once the page knows whether onsite forms run on it; `[]` when none will. */
332
365
  ready(): Promise<OnsiteTrace[]>;
333
366
  debug(): OnsiteTrace[];