@thg-agentic/hyper-personalisation-web 3.22.0 → 3.23.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/src/index.js CHANGED
@@ -45,7 +45,13 @@
45
45
  import { assignmentSession, surfaceExposureToken } from './assignment.js';
46
46
  import { hostConsent, servingHeaders } from './consent.js';
47
47
  import { createConsentGate } from './consent-gate.js';
48
- import { buildContext, getCookie, perRequest } from './context.js';
48
+ import {
49
+ buildContext,
50
+ getCookie,
51
+ localeTag,
52
+ perRequest,
53
+ scrubHref,
54
+ } from './context.js';
49
55
  import { createDecisionMemory, servedDecisions } from './decisions.js';
50
56
  import { createHeldStore, deviceIdFrom } from './device-storage.js';
51
57
  import {
@@ -109,6 +115,10 @@ export { emailSha256, getCookie };
109
115
 
110
116
  /** How a call made before the SDK started is replayed with the messageId and time it was made at. */
111
117
  const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
118
+ /** How ./onsite reads what it needs from a client it was handed. Not public API. */
119
+ const ONSITE_ACCESS = Symbol.for(
120
+ '@thg-agentic/hyper-personalisation-web.onsite-access',
121
+ );
112
122
 
113
123
  /** SHA-256 of a string as lowercase hex. Synchronous since 3.9 (sha256Hex); kept async for callers. */
114
124
  export async function sha256(value) {
@@ -394,6 +404,13 @@ export function createAnalytics(options) {
394
404
  }
395
405
  };
396
406
 
407
+ // A document restored from the back/forward cache resumes after the pages it led to: the tab's
408
+ // last page view is the one in storage, not this document's own.
409
+ if (typeof window !== 'undefined')
410
+ window.addEventListener('pageshow', (event) => {
411
+ if (event.persisted) lastPageMessageId = null;
412
+ });
413
+
397
414
  const writeLastPageMessageId = (id) => {
398
415
  lastPageMessageId = id || null;
399
416
  if (strict && gate.status !== 'granted') return;
@@ -419,8 +436,34 @@ export function createAnalytics(options) {
419
436
  /** The call being replayed: its first event keeps the messageId and time it was made with. */
420
437
  let replaying = null;
421
438
 
439
+ /**
440
+ * The basket this page last reported, in memory only: what a basket bar on this page shows,
441
+ * whatever the consent, since it is the shopper's own basket on screen and nothing is kept.
442
+ * Noted before the plan, the sample or beforeSend can drop the event.
443
+ */
444
+ let pageBasket = null;
445
+ const basketListeners = new Set();
446
+ /** Told once the consent banner is answered, granted or denied (./onsite). */
447
+ const consentListeners = new Set();
448
+
449
+ function noteBasket(partial) {
450
+ if (partial.type !== 'track' || partial.event !== 'Cart Updated') return;
451
+ const value = partial.properties?.value;
452
+ pageBasket =
453
+ typeof value === 'number' && Number.isFinite(value) && value >= 0
454
+ ? value
455
+ : null;
456
+ for (const listener of basketListeners)
457
+ try {
458
+ listener();
459
+ } catch (err) {
460
+ console.warn('[hp] a basket listener failed', err);
461
+ }
462
+ }
463
+
422
464
  /** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
423
465
  function emit(partial, ctxOverrides, force = false) {
466
+ noteBasket(partial);
424
467
  try {
425
468
  return compose(partial, ctxOverrides, force);
426
469
  } catch (err) {
@@ -447,6 +490,8 @@ export function createAnalytics(options) {
447
490
  };
448
491
 
449
492
  if (event.properties) event.properties = pruneProperties(event.properties);
493
+ if (typeof event.properties?.href === 'string')
494
+ event.properties.href = scrubHref(event.properties.href, cfg);
450
495
 
451
496
  // Outside the source's sample (3.18): nothing about this visitor is sent.
452
497
  if (!force && !sampledIn(event.anonymousId, cfg.sampleRate)) {
@@ -641,6 +686,13 @@ export function createAnalytics(options) {
641
686
  } catch (err) {
642
687
  console.warn('[hp] onConsentStatus observer failed', err);
643
688
  }
689
+ if (status === 'granted' || status === 'denied')
690
+ for (const listener of consentListeners)
691
+ try {
692
+ listener();
693
+ } catch (err) {
694
+ console.warn('[hp] a consent listener failed', err);
695
+ }
644
696
  },
645
697
  announce({ action, state, previous, withdrawal }) {
646
698
  // Withdrawn here, not only when the next event reaches recordVisit: a beforeSend that drops
@@ -958,6 +1010,11 @@ export function createAnalytics(options) {
958
1010
  newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
959
1011
  /** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
960
1012
  newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
1013
+ /**
1014
+ * Answers to an approved preference question, given in the account area, a quiz or an onsite
1015
+ * step after the sign-up. Answers sent with an onsite sign-up are the gateway's to report.
1016
+ */
1017
+ preferencesStated: (p, c) => track('Preferences Stated', p, c),
961
1018
  /** An alert request for one product: back in stock, a price drop or a launch. */
962
1019
  backInStockSubscribed: (p, c) => track('Back In Stock Subscribed', p, c),
963
1020
  productReviewed: (p, c) => track('Product Reviewed', p, c),
@@ -994,6 +1051,53 @@ export function createAnalytics(options) {
994
1051
  get anonymousId() {
995
1052
  return realId(perRequest(cfg.getAnonymousId, null));
996
1053
  },
1054
+ /** What ./onsite needs from this client: hosts, ids, consent and settings. Not public API. */
1055
+ [ONSITE_ACCESS]() {
1056
+ return {
1057
+ writeKey: cfg.writeKey,
1058
+ storageKey: storageKey(storageName('onsite'), cfg.propertyId),
1059
+ gatewayHost: cfg.apiHost,
1060
+ servingHost: cfg.servingHost ?? cfg.apiHost,
1061
+ settings: cfg.onsite ?? null,
1062
+ debug: cfg.debug === true,
1063
+ preview: previewing,
1064
+ anonymousId: () => realId(perRequest(cfg.getAnonymousId, null)),
1065
+ sessionId: () => realId(perRequest(cfg.getSessionId, null)),
1066
+ analytics: granted,
1067
+ personalisation: personalisationGranted,
1068
+ consent: () => cfg.getConsent(),
1069
+ headers: () => servingHeaders(gate?.state ?? null),
1070
+ pageUrl: () =>
1071
+ typeof location === 'undefined'
1072
+ ? null
1073
+ : scrubHref(location.href, cfg),
1074
+ locale: () => localeTag(perRequest(cfg.getLocale, cfg.locale)),
1075
+ /**
1076
+ * A basket bar's progress: the basket this page reported, else the last one this visit
1077
+ * kept (under personalisation consent), else null.
1078
+ */
1079
+ basketValue: () => {
1080
+ const value = getSessionSignals().basketValue;
1081
+ return pageBasket ?? (Number.isFinite(value) ? value : null);
1082
+ },
1083
+ /** Calls `listener` whenever the page reports its basket; answers how to stop. */
1084
+ onBasket: (listener) => {
1085
+ basketListeners.add(listener);
1086
+ return () => basketListeners.delete(listener);
1087
+ },
1088
+ /**
1089
+ * Whether the consent banner is answered. Without a consent adapter there is no banner to
1090
+ * wait for, so it is.
1091
+ */
1092
+ consentAnswered: () =>
1093
+ !gate || gate.status === 'granted' || gate.status === 'denied',
1094
+ /** Calls `listener` once the banner is answered; answers how to stop. */
1095
+ onConsentAnswered: (listener) => {
1096
+ consentListeners.add(listener);
1097
+ return () => consentListeners.delete(listener);
1098
+ },
1099
+ };
1100
+ },
997
1101
  /** `hp` and bootstrap replay a call made before the start through this. Not public API. */
998
1102
  [REPLAY](method, args, made) {
999
1103
  if (typeof this[method] !== 'function') return;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Date and number fields, only for a form that asks one: a bundler splits this import out of
3
+ * ./onsite, and the CDN build swaps this module for cdn/answers-loader.js, which loads
4
+ * dist/onsite-answers.min.js.
5
+ */
6
+ export const loadAnswers = () => import('./answers.js');
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Date and number questions in an onsite form (ADR 0064 part 6): their fields, and the answer each
3
+ * holds in its stored form (packages/control-plane/src/onsite/question-answers.ts), which the
4
+ * gateway checks again and never takes from the browser on trust.
5
+ *
6
+ * A birthday is a day and a month, and a year when asked, as separate fields (Klaviyo's birthday
7
+ * block; GOV.UK's date input for a date someone knows, never a calendar): `--MM-DD` without the
8
+ * year, `YYYY-MM-DD` with it. Any other date is the browser's own date field. A number is a text
9
+ * field with a numeric keyboard, which takes a comma for the point.
10
+ */
11
+
12
+ import { select } from './rules.js';
13
+
14
+ const pad = (n) => String(n).padStart(2, '0');
15
+ const OLDEST_AGE = 120;
16
+ /** The gateway's limits (question-answers.ts): the digits a number may have, and its size. */
17
+ const NUMBER = /^-?\d{1,10}(?:\.(\d{1,10}))?$/;
18
+ const NUMBER_LIMIT = 1e9;
19
+
20
+ /** The locale's word for a month, and whether it writes the month before the day (en-US). */
21
+ function calendar(locale) {
22
+ try {
23
+ const months = new Intl.DateTimeFormat(locale, {
24
+ month: 'long',
25
+ timeZone: 'UTC',
26
+ });
27
+ return {
28
+ name: (m) => months.format(Date.UTC(2016, m - 1, 1)),
29
+ monthFirst:
30
+ new Intl.DateTimeFormat(locale, { month: 'numeric', day: 'numeric' })
31
+ .formatToParts(0)
32
+ .find((p) => p.type !== 'literal')?.type === 'month',
33
+ };
34
+ } catch {
35
+ return { name: String, monthFirst: false };
36
+ }
37
+ }
38
+
39
+ const pick = (make, name, label, count, text, chosen) => {
40
+ const s = select(
41
+ make,
42
+ label,
43
+ label,
44
+ Array.from({ length: count }, (_, i) => [pad(i + 1), text(i + 1)]),
45
+ chosen,
46
+ );
47
+ s.name = name;
48
+ return s;
49
+ };
50
+
51
+ function input(make, name, label, mode, value) {
52
+ const i = make('input', 'i');
53
+ i.name = name;
54
+ i.inputMode = mode;
55
+ i.setAttribute('aria-label', label);
56
+ i.value = value ?? '';
57
+ return i;
58
+ }
59
+
60
+ /**
61
+ * The fields of a date or number question, into its fieldset, holding `value` when there is one. A
62
+ * birthday's day and month are ordered and named as `locale` writes them (the form's, else the
63
+ * page's language).
64
+ */
65
+ export function answerFields(set, q, make, value, locale) {
66
+ const f = q.format ?? {};
67
+ const row = make('div', f.kind === 'birthday' ? 'ph bd' : 'ph');
68
+ if (q.type === 'number') {
69
+ row.append(
70
+ input(make, 'hp_v', q.label, f.decimals ? 'decimal' : 'numeric', value),
71
+ );
72
+ if (f.unit) row.append(make('span', 'u', f.unit));
73
+ } else if (f.kind === 'birthday') {
74
+ const [, y, m, d] = /^(\d{4}|-)-(\d\d)-(\d\d)$/.exec(value ?? '') ?? [];
75
+ const c = calendar(locale || undefined);
76
+ const day = pick(make, 'hp_d', 'Day', 31, String, d);
77
+ const month = pick(make, 'hp_m', 'Month', 12, c.name, m);
78
+ row.append(...(c.monthFirst ? [month, day] : [day, month]));
79
+ if (f.year !== 'none') {
80
+ const year = input(make, 'hp_y', 'Year', 'numeric', y === '-' ? '' : y);
81
+ // Said on the field itself, not only to a screen reader.
82
+ year.placeholder = f.year === 'required' ? 'Year' : 'Year (optional)';
83
+ year.autocomplete = 'bday-year';
84
+ year.maxLength = 4;
85
+ row.append(year);
86
+ }
87
+ } else {
88
+ const i = input(make, 'hp_v', q.label, 'none', value);
89
+ i.type = 'date';
90
+ i.min = '1900-01-01';
91
+ i.max = '2100-12-31';
92
+ row.append(i);
93
+ }
94
+ set.append(row);
95
+ }
96
+
97
+ /** A number as the form's language writes it, else as the browser's does. */
98
+ function written(n, locale) {
99
+ try {
100
+ return new Intl.NumberFormat(locale || undefined).format(n);
101
+ } catch {
102
+ return String(n);
103
+ }
104
+ }
105
+
106
+ const realDate = (y, m, d) => {
107
+ const t = new Date(Date.UTC(y, m - 1, d));
108
+ return t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
109
+ };
110
+
111
+ /**
112
+ * A typed number as `Number` reads it. Spaces go; with both a point and a comma, the last is the
113
+ * decimal mark and the other groups thousands; a comma before groups of exactly three digits groups
114
+ * them (`10,000`); any other comma is the decimal mark (`7,5`).
115
+ */
116
+ export function plainNumber(typed) {
117
+ const text = typed.replace(/\s/g, '');
118
+ const comma = text.lastIndexOf(',');
119
+ const point = text.lastIndexOf('.');
120
+ if (comma >= 0 && point >= 0)
121
+ return comma > point
122
+ ? text.replace(/\./g, '').replace(',', '.')
123
+ : text.replace(/,/g, '');
124
+ if (/^-?\d{1,3}(?:,\d{3})+$/.test(text)) return text.replace(/,/g, '');
125
+ return text.replace(',', '.');
126
+ }
127
+
128
+ /**
129
+ * The fieldset's answer: `{ value }` (null when it was left empty), or `{ error, input }` for what
130
+ * the shopper has to correct, its numbers written as `locale` writes them (50,000).
131
+ */
132
+ export function answerOf(set, q, now = Date.now(), locale = undefined) {
133
+ const f = q.format ?? {};
134
+ const at = (name) => set.querySelector(`[name=${name}]`);
135
+ if (q.type === 'number') {
136
+ const i = at('hp_v');
137
+ const text = plainNumber(i.value);
138
+ if (!text) return { value: null };
139
+ const n = Number(text);
140
+ const match = NUMBER.exec(text);
141
+ return match &&
142
+ (match[1]?.replace(/0+$/, '').length ?? 0) <= (f.decimals ? 2 : 0) &&
143
+ Math.abs(n) <= NUMBER_LIMIT &&
144
+ !(n < f.min || n > f.max)
145
+ ? { value: String(n === 0 ? 0 : n) }
146
+ : {
147
+ error: `Enter a ${f.decimals ? '' : 'whole '}number${f.min != null && f.max != null ? ` from ${written(f.min, locale)} to ${written(f.max, locale)}` : ''}.`,
148
+ input: i,
149
+ };
150
+ }
151
+ const wrong = (input) => ({ error: 'Enter a real date.', input });
152
+ if (f.kind !== 'birthday') {
153
+ const i = at('hp_v');
154
+ if (!i.value) return { value: null };
155
+ return i.value >= i.min && i.value <= i.max ? { value: i.value } : wrong(i);
156
+ }
157
+ const [d, m, y] = ['hp_d', 'hp_m', 'hp_y'].map(at);
158
+ const year = y?.value.trim() ?? '';
159
+ if (!d.value && !m.value && !year) return { value: null };
160
+ if (!d.value || !m.value) return wrong(d.value ? m : d);
161
+ if (!year)
162
+ return f.year === 'required' || !realDate(2016, +m.value, +d.value)
163
+ ? wrong(y ?? d)
164
+ : { value: `--${m.value}-${d.value}` };
165
+ const value = `${year}-${m.value}-${d.value}`;
166
+ // The gateway's own bounds (question-answers.ts dateAnswer): up to tomorrow, anywhere on Earth,
167
+ // and no earlier than this day OLDEST_AGE years ago, so a date taken here is never refused there.
168
+ const today = new Date(now).toISOString().slice(0, 10);
169
+ const latest = new Date(now + 86_400_000).toISOString().slice(0, 10);
170
+ const earliest = `${+today.slice(0, 4) - OLDEST_AGE}${today.slice(4)}`;
171
+ return /^\d{4}$/.test(year) &&
172
+ realDate(+year, +m.value, +d.value) &&
173
+ value <= latest &&
174
+ value >= earliest
175
+ ? { value }
176
+ : wrong(y);
177
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The five calls a form makes, each authenticated with the page's publishable key and never with a
3
+ * cookie: `POST /v1/decisions` and `POST /v1/offers/claim` on the public API (with `hp-consent`, as
4
+ * every serving request), `GET /v1/capture/challenge`, `POST /v1/capture` and
5
+ * `POST /v1/capture/answers` on the ingest gateway.
6
+ * Every call resolves, to `{ status, body }` (status 0 when the network failed).
7
+ */
8
+
9
+ const DEFAULT_CHALLENGE = '/v1/capture/challenge';
10
+
11
+ /** The settings' challenge path, only when it is a path on the gateway. */
12
+ const challengePath = (path) =>
13
+ typeof path === 'string' && /^\/v1\/[\w/-]+$/.test(path)
14
+ ? path
15
+ : DEFAULT_CHALLENGE;
16
+
17
+ /** How long one claim request may take before it counts as failed. */
18
+ const CLAIM_TIMEOUT_MS = 8000;
19
+
20
+ /**
21
+ * @param {object} access What the client exposes to ./onsite (index.js, ONSITE_ACCESS).
22
+ * @param {{ servingHost?: string, gatewayHost?: string, fetch?: typeof fetch, challenge?: string }} [options]
23
+ */
24
+ export function createApi(access, options = {}) {
25
+ const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
26
+ const serving = options.servingHost ?? access.servingHost;
27
+ const gateway = options.gatewayHost ?? access.gatewayHost;
28
+
29
+ async function call(
30
+ url,
31
+ { method = 'GET', body, consent = false, timeoutMs } = {},
32
+ ) {
33
+ if (!doFetch) return { status: 0, body: null };
34
+ try {
35
+ const res = await doFetch(url, {
36
+ method,
37
+ credentials: 'omit',
38
+ signal: timeoutMs && AbortSignal.timeout?.(timeoutMs),
39
+ headers: {
40
+ accept: 'application/json',
41
+ authorization: `Bearer ${access.writeKey}`,
42
+ ...(body ? { 'content-type': 'application/json' } : {}),
43
+ ...(consent ? access.headers() : {}),
44
+ },
45
+ ...(body ? { body: JSON.stringify(body) } : {}),
46
+ });
47
+ let parsed = null;
48
+ try {
49
+ parsed = await res.json();
50
+ } catch {
51
+ /* no body */
52
+ }
53
+ return { status: res.status, body: parsed };
54
+ } catch {
55
+ return { status: 0, body: null };
56
+ }
57
+ }
58
+
59
+ return {
60
+ /** The decision for one slot, as the page's other serving calls ask for it, with its pin. */
61
+ decide: (slot, pin) =>
62
+ call(`${serving}/v1/decisions`, {
63
+ method: 'POST',
64
+ consent: true,
65
+ body: {
66
+ slots: [
67
+ pin
68
+ ? {
69
+ key: slot,
70
+ pin: { experiment: pin[0], arm: pin[1], split: pin[2] },
71
+ }
72
+ : slot,
73
+ ],
74
+ identifier_value: access.anonymousId() ?? undefined,
75
+ session_id: access.sessionId() ?? undefined,
76
+ ...(access.preview ? { preview: access.preview } : {}),
77
+ },
78
+ }),
79
+ challenge: () => call(`${gateway}${challengePath(options.challenge)}`),
80
+ capture: (body) => call(`${gateway}/v1/capture`, { method: 'POST', body }),
81
+ // Answers given after the email step, with its address and receipt.
82
+ answers: (body) =>
83
+ call(`${gateway}/v1/capture/answers`, { method: 'POST', body }),
84
+ // Bounded, so the page's `submitted`, which waits for the claim, always comes.
85
+ claim: (body) =>
86
+ call(`${serving}/v1/offers/claim`, {
87
+ method: 'POST',
88
+ consent: true,
89
+ body,
90
+ timeoutMs: CLAIM_TIMEOUT_MS,
91
+ }),
92
+ };
93
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * What the caps count, per slot, as opaque numbers: shows this session (sessionStorage), and shows
3
+ * ever, when it was last closed and whether it was submitted (localStorage), plus one subscribed
4
+ * flag, whether a teaser's tab is out, when a covering form last showed and the version an A/B test
5
+ * pinned for the visit (sessionStorage), and the
6
+ * last code this browser claimed, for a promo reminder. Never an address, a number or an answer.
7
+ * Every access is wrapped: without storage the caps hold for the page view only.
8
+ *
9
+ * The counts follow what the shopper did (saw, closed, signed up) and serve only the caps: UI
10
+ * preference storage, set before consent like Wisepops and Klaviyo (ICO, the strictly necessary
11
+ * exception). The subscribed flag and the claimed code mark the shopper rather than the form, so
12
+ * they outlive the visit only with personalisation consent; without it they hold for the session.
13
+ */
14
+
15
+ function store(get, key) {
16
+ let memory = {};
17
+ const read = () => {
18
+ try {
19
+ const parsed = JSON.parse(get().getItem(key));
20
+ if (parsed && typeof parsed === 'object') memory = parsed;
21
+ } catch {
22
+ /* no storage, or not ours: what is in memory */
23
+ }
24
+ return memory;
25
+ };
26
+ const write = (change) => {
27
+ const next = read();
28
+ change(next);
29
+ memory = next;
30
+ try {
31
+ get().setItem(key, JSON.stringify(next));
32
+ } catch {
33
+ /* kept in memory for this page */
34
+ }
35
+ };
36
+ return { read, write };
37
+ }
38
+
39
+ /**
40
+ * @param {string} key The storage key, storageName('onsite') scoped to the property.
41
+ * @param {() => boolean} [keep] Whether the shopper's own markers may outlive the visit.
42
+ */
43
+ export function createCounters(key, keep = () => true) {
44
+ const session = store(() => sessionStorage, key);
45
+ const local = store(() => localStorage, key);
46
+ /** Where a new marker goes: the browser's store while consent holds, else the visit's. */
47
+ const own = () => (keep() ? local : session);
48
+ /** Consent withdrawn: the markers an earlier consented visit kept are forgotten. */
49
+ const forgetKept = () => {
50
+ const kept = local.read();
51
+ if (kept.sub !== undefined || kept.k !== undefined)
52
+ local.write((all) => {
53
+ delete all.sub;
54
+ delete all.k;
55
+ });
56
+ };
57
+ /** The stores the shopper's markers are read from: the browser's only while consent holds. */
58
+ const markers = () => {
59
+ if (keep()) return [session.read(), local.read()];
60
+ forgetKept();
61
+ return [session.read()];
62
+ };
63
+ const slotOf = (all, slot) => {
64
+ all.f ??= {};
65
+ all.f[slot] ??= {};
66
+ return all.f[slot];
67
+ };
68
+
69
+ return {
70
+ /** `{ session, lifetime, closedAt, submitted }` for `slot`. */
71
+ seen(slot) {
72
+ const ever = local.read().f?.[slot] ?? {};
73
+ return {
74
+ session: session.read().f?.[slot]?.n ?? 0,
75
+ lifetime: ever.n ?? 0,
76
+ closedAt: ever.c ?? null,
77
+ submitted: ever.s === 1,
78
+ };
79
+ },
80
+ subscribed: () => markers().some((all) => all.sub === 1),
81
+ shown(slot) {
82
+ session.write((all) => {
83
+ const s = slotOf(all, slot);
84
+ s.n = (s.n ?? 0) + 1;
85
+ });
86
+ local.write((all) => {
87
+ const s = slotOf(all, slot);
88
+ s.n = (s.n ?? 0) + 1;
89
+ });
90
+ },
91
+ closed(slot, now = Date.now()) {
92
+ local.write((all) => {
93
+ slotOf(all, slot).c = now;
94
+ });
95
+ },
96
+ /** Whether a teaser's tab is out, unopened and undismissed: it is kept across pages. */
97
+ tab(slot, on) {
98
+ session.write((all) => {
99
+ const s = slotOf(all, slot);
100
+ if (on) s.t = 1;
101
+ else delete s.t;
102
+ });
103
+ },
104
+ tabOut: (slot) => session.read().f?.[slot]?.t === 1,
105
+ /**
106
+ * `[test, arm, split]`: the version this visit was shown in the slot's A/B test (ADR 0065), sent back
107
+ * with the next decision so the visit keeps it. Only an arm decided in a test has one.
108
+ */
109
+ pin(slot, value) {
110
+ if (value === undefined) return session.read().f?.[slot]?.p ?? null;
111
+ session.write((all) => {
112
+ slotOf(all, slot).p = value;
113
+ });
114
+ },
115
+ /** When a covering form last showed this session, for the property's gap between them. */
116
+ coveredAt: () => session.read().o ?? null,
117
+ covered(now = Date.now()) {
118
+ session.write((all) => {
119
+ all.o = now;
120
+ });
121
+ },
122
+ /** The last code this browser claimed, kept 14 days for a reminder; never an address. */
123
+ code(value, now = Date.now()) {
124
+ if (value === undefined) {
125
+ const [kept] = markers()
126
+ .flatMap((all) => (all.k ? [all.k] : []))
127
+ .sort((a, b) => b.at - a.at);
128
+ return kept && now - kept.at < 14 * 86_400_000 ? kept.c : null;
129
+ }
130
+ own().write((all) => {
131
+ all.k = { c: value, at: now };
132
+ });
133
+ return value;
134
+ },
135
+ submitted(slot) {
136
+ local.write((all) => {
137
+ slotOf(all, slot).s = 1;
138
+ });
139
+ own().write((all) => {
140
+ all.sub = 1;
141
+ });
142
+ },
143
+ };
144
+ }