@thg-agentic/hyper-personalisation-web 3.22.2 → 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.
@@ -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
+ }