@thg-agentic/hyper-personalisation-web 3.22.2 → 3.24.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/README.md +166 -2
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -0
- package/dist/onsite-render.min.js +182 -0
- package/dist/onsite-wheel.min.js +1 -0
- package/dist/onsite.min.js +1 -0
- package/dist/snippet.min.js +1 -1
- package/package.json +19 -3
- package/src/bootstrap.js +33 -2
- package/src/context.js +20 -7
- package/src/ga4.js +3 -4
- package/src/hp.js +2 -0
- package/src/index.js +100 -5
- package/src/onsite/answers-loader.js +6 -0
- package/src/onsite/answers.js +177 -0
- package/src/onsite/api.js +93 -0
- package/src/onsite/counters.js +144 -0
- package/src/onsite/index.js +525 -0
- package/src/onsite/pow.js +127 -0
- package/src/onsite/preview.js +97 -0
- package/src/onsite/render-loader.js +5 -0
- package/src/onsite/render.js +1507 -0
- package/src/onsite/rich-text.js +73 -0
- package/src/onsite/rules.js +250 -0
- package/src/onsite/styles.js +212 -0
- package/src/onsite/triggers.js +165 -0
- package/src/onsite/wheel-loader.js +5 -0
- package/src/onsite/wheel.js +196 -0
- package/src/onsite-facade.js +123 -0
- package/src/onsite-loader.js +5 -0
- package/src/settings.js +18 -0
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +5 -0
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +15 -1
- package/types/events.d.ts +68 -5
- package/types/hp.d.ts +4 -1
- package/types/index.d.ts +10 -0
- package/types/onsite-preview.d.ts +58 -0
- package/types/onsite-wheel.d.ts +11 -0
- package/types/onsite.d.ts +203 -0
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 {
|
|
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) {
|
|
@@ -426,8 +436,34 @@ export function createAnalytics(options) {
|
|
|
426
436
|
/** The call being replayed: its first event keeps the messageId and time it was made with. */
|
|
427
437
|
let replaying = null;
|
|
428
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
|
+
|
|
429
464
|
/** Tracking never throws into the host: an event that fails to build or deliver is dropped. */
|
|
430
465
|
function emit(partial, ctxOverrides, force = false) {
|
|
466
|
+
noteBasket(partial);
|
|
431
467
|
try {
|
|
432
468
|
return compose(partial, ctxOverrides, force);
|
|
433
469
|
} catch (err) {
|
|
@@ -650,6 +686,13 @@ export function createAnalytics(options) {
|
|
|
650
686
|
} catch (err) {
|
|
651
687
|
console.warn('[hp] onConsentStatus observer failed', err);
|
|
652
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
|
+
}
|
|
653
696
|
},
|
|
654
697
|
announce({ action, state, previous, withdrawal }) {
|
|
655
698
|
// Withdrawn here, not only when the next event reaches recordVisit: a beforeSend that drops
|
|
@@ -674,15 +717,15 @@ export function createAnalytics(options) {
|
|
|
674
717
|
gate.start();
|
|
675
718
|
}
|
|
676
719
|
|
|
720
|
+
/** The property's holdout split from the settings, as a serving response would state it. */
|
|
721
|
+
const configuredShares = () =>
|
|
722
|
+
cfg.holdout ? holdoutShares(cfg.holdout) : null;
|
|
723
|
+
|
|
677
724
|
/**
|
|
678
725
|
* This shopper's group, as the edge and the public API decide it: `holdout` and `excluded` (outside
|
|
679
726
|
* the property's ramp) both get the default everywhere, so personalise in the page only for
|
|
680
727
|
* `personalised`.
|
|
681
728
|
*/
|
|
682
|
-
/** The property's holdout split from the settings, as a serving response would state it. */
|
|
683
|
-
const configuredShares = () =>
|
|
684
|
-
cfg.holdout ? holdoutShares(cfg.holdout) : null;
|
|
685
|
-
|
|
686
729
|
const personalisationGroup = () =>
|
|
687
730
|
holdoutAssignment(cfg.holdout, realId(perRequest(cfg.getAnonymousId, null)))
|
|
688
731
|
.group;
|
|
@@ -967,6 +1010,11 @@ export function createAnalytics(options) {
|
|
|
967
1010
|
newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
|
|
968
1011
|
/** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
|
|
969
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),
|
|
970
1018
|
/** An alert request for one product: back in stock, a price drop or a launch. */
|
|
971
1019
|
backInStockSubscribed: (p, c) => track('Back In Stock Subscribed', p, c),
|
|
972
1020
|
productReviewed: (p, c) => track('Product Reviewed', p, c),
|
|
@@ -1003,6 +1051,53 @@ export function createAnalytics(options) {
|
|
|
1003
1051
|
get anonymousId() {
|
|
1004
1052
|
return realId(perRequest(cfg.getAnonymousId, null));
|
|
1005
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
|
+
},
|
|
1006
1101
|
/** `hp` and bootstrap replay a call made before the start through this. Not public API. */
|
|
1007
1102
|
[REPLAY](method, args, made) {
|
|
1008
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
|
+
}
|