@thg-agentic/hyper-personalisation-web 3.24.1 → 3.26.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 +100 -25
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -1
- package/dist/onsite-audiences.min.js +1 -0
- package/dist/onsite-render.min.js +2 -2
- package/dist/onsite-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +4 -3
- package/src/bootstrap.js +114 -49
- package/src/consent-gate.js +8 -5
- package/src/context.js +2 -1
- package/src/device-storage.js +4 -4
- package/src/index.js +142 -10
- package/src/onsite/api.js +22 -5
- package/src/onsite/audiences-loader.js +6 -0
- package/src/onsite/counters.js +2 -2
- package/src/onsite/index.js +52 -1
- package/src/onsite/render.js +49 -18
- package/src/onsite/rules.js +20 -5
- package/src/onsite/visit-audiences.js +94 -0
- package/src/onsite/wheel.js +61 -13
- package/src/settings.js +30 -2
- package/src/shape.js +4 -0
- package/src/social-proof.js +5 -1
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +8 -2
- package/src/transport.js +22 -2
- package/src/version.js +1 -1
- package/src/visit-campaign.js +168 -0
- package/types/bootstrap.d.ts +10 -2
- package/types/events.d.ts +30 -2
- package/types/hp.d.ts +3 -2
- package/types/index.d.ts +10 -3
- package/types/onsite.d.ts +9 -0
package/src/onsite/rules.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* counters, so the eligibility trace (`hp.onsite.debug()`) says exactly what decided.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { arrivedFrom } from '../visit-campaign.js';
|
|
7
|
+
|
|
6
8
|
export const FORMATS = [
|
|
7
9
|
'popup',
|
|
8
10
|
'sheet',
|
|
@@ -39,6 +41,11 @@ const strings = (v) =>
|
|
|
39
41
|
Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
|
|
40
42
|
const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
|
|
41
43
|
|
|
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
|
+
|
|
42
49
|
/** A form from the settings as the runtime uses it, or null when it cannot be used. */
|
|
43
50
|
export function readForm(raw) {
|
|
44
51
|
if (!isObject(raw) || typeof raw.slot !== 'string' || raw.slot === '')
|
|
@@ -55,6 +62,7 @@ export function readForm(raw) {
|
|
|
55
62
|
devices: strings(raw.devices),
|
|
56
63
|
aliases: strings(raw.aliases),
|
|
57
64
|
audiences: strings(raw.audiences),
|
|
65
|
+
arrived_from: arrivedRules(raw.arrived_from),
|
|
58
66
|
caps: {
|
|
59
67
|
per_session: count(caps.per_session),
|
|
60
68
|
lifetime: count(caps.lifetime),
|
|
@@ -123,25 +131,32 @@ const at = (iso) => (typeof iso === 'string' ? Date.parse(iso) : Number.NaN);
|
|
|
123
131
|
/**
|
|
124
132
|
* A form the site opens itself (`manual`: its own sign-up button) is the shopper asking for it, so
|
|
125
133
|
* how often it has shown does not hold it back: no caps, gap or page view allowance, as Klaviyo's
|
|
126
|
-
* custom-triggered forms ignore their frequency settings. Its pages, devices, dates and
|
|
127
|
-
* still do.
|
|
134
|
+
* custom-triggered forms ignore their frequency settings. Its pages, devices, campaign, dates and
|
|
135
|
+
* audiences still do.
|
|
128
136
|
*/
|
|
129
137
|
export const ASKED = (form) => form.trigger.type === 'manual';
|
|
130
138
|
|
|
131
139
|
/**
|
|
132
140
|
* Every reason `form` may not show now, empty when it may. `env` is the page:
|
|
133
|
-
* `{ path, device, now, seen, subscribed, personalisation, preview, audiences }`, where
|
|
134
|
-
* the slot's counters and `audiences` the
|
|
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
|
|
143
|
+
* session audiences the host gave, or null. The campaign is context about this visit, as the page
|
|
144
|
+
* is, so it needs no personalisation consent. A form limited to audiences needs personalisation
|
|
145
|
+
* consent; whether the visitor is in one is the server's to decide (`POST /v1/decisions`), unless
|
|
146
|
+
* the host gave its own list.
|
|
135
147
|
*/
|
|
136
148
|
export function blockers(form, env) {
|
|
137
149
|
const reasons = [];
|
|
138
150
|
if (!pageAllowed(form.pages, env.path)) reasons.push('page not included');
|
|
139
151
|
if (form.devices.length && !form.devices.includes(env.device))
|
|
140
152
|
reasons.push(`not on ${env.device}`);
|
|
153
|
+
if (!arrivedFrom(form.arrived_from, env.campaign))
|
|
154
|
+
reasons.push('not from its campaign');
|
|
141
155
|
const starts = at(form.window.starts_at);
|
|
142
156
|
const ends = at(form.window.ends_at);
|
|
143
157
|
if (env.now < starts || env.now >= ends) reasons.push('outside its dates');
|
|
144
|
-
|
|
158
|
+
// A trader previewing a limited form sees it, as the server answers a preview.
|
|
159
|
+
if (form.audiences.length && !env.preview) {
|
|
145
160
|
if (!env.personalisation)
|
|
146
161
|
reasons.push('audiences need personalisation consent');
|
|
147
162
|
else if (
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session audiences whose rules hold in this visit, for a form limited to audiences (ADR 0064):
|
|
3
|
+
* the server decides membership, but the live tier counts only product views and cart adds, so the
|
|
4
|
+
* browser evaluates the datafile's session rules and sends the slugs as `visit_audiences`. Rules
|
|
5
|
+
* only: `requires`, MIXED and targetability are the server's to apply. A chunk of its own
|
|
6
|
+
* (./audiences-loader.js), loaded with the datafile as the runtime starts, only when a live form is
|
|
7
|
+
* limited and the visitor allowed personalisation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { catalogueBrowse, sessionRulesHold } from '../session/rules.js';
|
|
11
|
+
|
|
12
|
+
/** What the decide request accepts: more, or a longer slug, and it ignores the lot. */
|
|
13
|
+
const MAX_AUDIENCES = 100;
|
|
14
|
+
/** Past any slug the console makes: a name of at most 100 characters, and a `-2` to tell twins apart. */
|
|
15
|
+
const MAX_SLUG = 128;
|
|
16
|
+
|
|
17
|
+
const isSlug = (slug) =>
|
|
18
|
+
typeof slug === 'string' && slug !== '' && slug.length <= MAX_SLUG;
|
|
19
|
+
|
|
20
|
+
/** The datafile for each onsite runtime's api: a 200 kept for the page, anything else asked again. */
|
|
21
|
+
const datafiles = new WeakMap();
|
|
22
|
+
|
|
23
|
+
/** Start fetching the datafile, so a form that fires later does not wait for it. */
|
|
24
|
+
export function prefetch(api) {
|
|
25
|
+
if (!datafiles.has(api)) {
|
|
26
|
+
const fetching = api
|
|
27
|
+
.datafile()
|
|
28
|
+
.then((res) => (res.status === 200 ? res.body : null))
|
|
29
|
+
.catch(() => null);
|
|
30
|
+
datafiles.set(api, fetching);
|
|
31
|
+
fetching.then((body) => {
|
|
32
|
+
if (!body && datafiles.get(api) === fetching) datafiles.delete(api);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return datafiles.get(api);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The slugs to send: those whose rules hold in this visit (the datafile fetched once, the visit read
|
|
40
|
+
* each time) and those the page gave, once each, at most MAX_AUDIENCES of them. Past that, the form's
|
|
41
|
+
* own audiences and what they require go first, so the cap never drops the ones it asks about.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ datafile: () => Promise<{ status: number, body: any }> }} api ./api.js
|
|
44
|
+
* @param {() => object | null | undefined} [signals] This visit's session signals, kept only under
|
|
45
|
+
* personalisation consent.
|
|
46
|
+
* @param {string[]} [targets] The audiences the form is limited to.
|
|
47
|
+
* @param {string[]} [given] The session audiences the page gave (`startOnsite`'s `audiences()`).
|
|
48
|
+
* @returns {Promise<string[]>}
|
|
49
|
+
*/
|
|
50
|
+
export async function visitAudiences(api, signals, targets = [], given = []) {
|
|
51
|
+
const datafile = await prefetch(api);
|
|
52
|
+
const visit = signals?.();
|
|
53
|
+
const session = Array.isArray(datafile?.session_audiences)
|
|
54
|
+
? datafile.session_audiences
|
|
55
|
+
: [];
|
|
56
|
+
const held = datafile && visit ? holdingAudiences(datafile, visit) : [];
|
|
57
|
+
return firstWanted([...held, ...given], targets, session);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `slugs`, once each, `targets` and their transitive `requires` first, capped. */
|
|
61
|
+
export function firstWanted(slugs, targets, session) {
|
|
62
|
+
const requires = new Map(
|
|
63
|
+
session.map((a) => [a?.slug, Array.isArray(a?.requires) ? a.requires : []]),
|
|
64
|
+
);
|
|
65
|
+
const wanted = new Set();
|
|
66
|
+
const visit = (slug) => {
|
|
67
|
+
if (wanted.has(slug)) return;
|
|
68
|
+
wanted.add(slug);
|
|
69
|
+
for (const required of requires.get(slug) ?? []) visit(required);
|
|
70
|
+
};
|
|
71
|
+
for (const slug of targets) visit(slug);
|
|
72
|
+
const unique = [...new Set(slugs.filter(isSlug))];
|
|
73
|
+
return [
|
|
74
|
+
...unique.filter((slug) => wanted.has(slug)),
|
|
75
|
+
...unique.filter((slug) => !wanted.has(slug)),
|
|
76
|
+
].slice(0, MAX_AUDIENCES);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{ session_audiences?: unknown, browse_categories?: unknown }} datafile `GET /v1/audiences/datafile`.
|
|
81
|
+
* @param {object} signals This visit's session signals.
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
export function holdingAudiences(datafile, signals) {
|
|
85
|
+
const audiences = Array.isArray(datafile?.session_audiences)
|
|
86
|
+
? datafile.session_audiences
|
|
87
|
+
: [];
|
|
88
|
+
const visit = catalogueBrowse(signals, datafile.browse_categories ?? null);
|
|
89
|
+
return audiences
|
|
90
|
+
.filter(
|
|
91
|
+
(audience) => isSlug(audience?.slug) && sessionRulesHold(audience, visit),
|
|
92
|
+
)
|
|
93
|
+
.map((audience) => audience.slug);
|
|
94
|
+
}
|
package/src/onsite/wheel.js
CHANGED
|
@@ -16,6 +16,8 @@ const LABEL_TO = 0.95;
|
|
|
16
16
|
/** Its type: as large as fits up to the first, never under the second (about 11px on a 320px wheel). */
|
|
17
17
|
const FONT_MAX = 0.105;
|
|
18
18
|
const FONT_MIN = 0.082;
|
|
19
|
+
/** The least a label is shrunk to before it is shortened: a smaller label beats a cut one. */
|
|
20
|
+
const FONT_FLOOR = 0.062;
|
|
19
21
|
/** A bold sans character's width, a little over the average, so a label is never cut. */
|
|
20
22
|
const EM_PER_CHAR = 0.62;
|
|
21
23
|
const LINE_HEIGHT = 1.15;
|
|
@@ -49,8 +51,9 @@ const point = (deg, r = 1) => {
|
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* How a prize label fits its slice: on one line at the largest size that fits; else on two, broken
|
|
52
|
-
* at the space nearest its middle; else
|
|
53
|
-
* width near the hub bounds the size too, so many slices draw
|
|
54
|
+
* at the space nearest its middle; else the same again down to a smaller floor; else shortened with
|
|
55
|
+
* an ellipsis at the floor. A slice's width near the hub bounds the size too, so many slices draw
|
|
56
|
+
* smaller type.
|
|
54
57
|
*
|
|
55
58
|
* @returns {{ lines: string[], size: number }}
|
|
56
59
|
*/
|
|
@@ -61,20 +64,60 @@ export function fitLabel(label, slices) {
|
|
|
61
64
|
const top = (lines) =>
|
|
62
65
|
Math.max(FONT_MIN, Math.min(FONT_MAX, across / (lines * LINE_HEIGHT)));
|
|
63
66
|
const sizeFor = (text) => room / (Math.max(1, text.length) * EM_PER_CHAR);
|
|
64
|
-
const one = sizeFor(label);
|
|
65
|
-
if (one >= FONT_MIN) return { lines: [label], size: Math.min(top(1), one) };
|
|
66
67
|
const spaces = [...label.matchAll(/ /g)].map((m) => m.index);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
const middle = label.length / 2;
|
|
69
|
+
const at = spaces.length
|
|
70
|
+
? spaces.reduce((a, b) =>
|
|
71
|
+
Math.abs(b - middle) < Math.abs(a - middle) ? b : a,
|
|
72
|
+
)
|
|
73
|
+
: -1;
|
|
74
|
+
const two = at < 0 ? null : [label.slice(0, at), label.slice(at + 1)];
|
|
75
|
+
for (const least of [FONT_MIN, FONT_FLOOR]) {
|
|
76
|
+
const one = sizeFor(label);
|
|
77
|
+
if (one >= least) return { lines: [label], size: Math.min(top(1), one) };
|
|
78
|
+
if (two) {
|
|
79
|
+
const size = Math.min(top(2), ...two.map(sizeFor));
|
|
80
|
+
if (size >= least) return { lines: two, size };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const fits = Math.floor(room / (FONT_FLOOR * EM_PER_CHAR));
|
|
84
|
+
return {
|
|
85
|
+
lines: [`${label.slice(0, fits - 1).trimEnd()}…`],
|
|
86
|
+
size: FONT_FLOOR,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Hold drawn labels to their room by their real width, once the page lays them out: the estimate
|
|
92
|
+
* in `fitLabel` cannot know the site's font, and a wide one ran "15% off" past its slice's edge.
|
|
93
|
+
* A line too wide is shrunk to fit, down to the floor, and squeezed to the room past it. Labels not
|
|
94
|
+
* laid out yet (a detached wheel) measure nothing and are left as estimated.
|
|
95
|
+
*
|
|
96
|
+
* @param {SVGTextElement[]} texts
|
|
97
|
+
*/
|
|
98
|
+
export function fitDrawnLabels(texts) {
|
|
99
|
+
const room = LABEL_TO - LABEL_FROM;
|
|
100
|
+
const width = (span) => span.getComputedTextLength?.() ?? 0;
|
|
101
|
+
for (const text of texts) {
|
|
102
|
+
const spans = [...text.querySelectorAll('tspan')];
|
|
103
|
+
const widest = Math.max(0, ...spans.map(width));
|
|
104
|
+
if (!(widest > room)) continue;
|
|
105
|
+
const size = Math.max(
|
|
106
|
+
FONT_FLOOR,
|
|
107
|
+
(Number(text.getAttribute('font-size')) * room) / widest,
|
|
71
108
|
);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
109
|
+
text.setAttribute('font-size', size.toFixed(4));
|
|
110
|
+
spans.forEach((span, k) => {
|
|
111
|
+
span.setAttribute(
|
|
112
|
+
'y',
|
|
113
|
+
((k - (spans.length - 1) / 2) * size * LINE_HEIGHT).toFixed(4),
|
|
114
|
+
);
|
|
115
|
+
if (width(span) > room) {
|
|
116
|
+
span.setAttribute('textLength', room.toFixed(4));
|
|
117
|
+
span.setAttribute('lengthAdjust', 'spacingAndGlyphs');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
75
120
|
}
|
|
76
|
-
const fits = Math.floor(room / (FONT_MIN * EM_PER_CHAR));
|
|
77
|
-
return { lines: [`${label.slice(0, fits - 1).trimEnd()}…`], size: FONT_MIN };
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
/**
|
|
@@ -108,6 +151,7 @@ export function mountWheel(container, prizes) {
|
|
|
108
151
|
];
|
|
109
152
|
wheel.append(paint(make('circle', { r: 1 }), BG));
|
|
110
153
|
const lights = [];
|
|
154
|
+
const texts = [];
|
|
111
155
|
labels.forEach((label, i) => {
|
|
112
156
|
const [tone, ink] = tones[n % 2 === 1 && i === n - 1 ? 2 : i % 2];
|
|
113
157
|
wheel.append(
|
|
@@ -148,6 +192,7 @@ export function mountWheel(container, prizes) {
|
|
|
148
192
|
text.append(span);
|
|
149
193
|
});
|
|
150
194
|
wheel.append(text);
|
|
195
|
+
texts.push(text);
|
|
151
196
|
for (const at of [i * size, (i + 0.5) * size]) {
|
|
152
197
|
const [cx, cy] = point(at, 1.075).split(' ');
|
|
153
198
|
lights.push(paint(make('circle', { cx, cy, r: 0.024 }), BG));
|
|
@@ -170,6 +215,9 @@ export function mountWheel(container, prizes) {
|
|
|
170
215
|
),
|
|
171
216
|
);
|
|
172
217
|
container.append(svg);
|
|
218
|
+
fitDrawnLabels(texts);
|
|
219
|
+
// A wheel drawn before its step is shown has no layout yet: measured again on the next frame.
|
|
220
|
+
globalThis.requestAnimationFrame?.(() => fitDrawnLabels(texts));
|
|
173
221
|
|
|
174
222
|
return {
|
|
175
223
|
spinTo(index) {
|
package/src/settings.js
CHANGED
|
@@ -21,6 +21,18 @@ const isStringArray = (v) =>
|
|
|
21
21
|
|
|
22
22
|
export const DISABLED = Object.freeze({ enabled: false });
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* What a page with no cached settings starts on when the API cannot be asked: collection only, every
|
|
26
|
+
* other setting read as absent. Settings switch features on; their absence never switches capture
|
|
27
|
+
* off (Snowplow's tracker needs no remote config to collect). Consent is still the page's CMP,
|
|
28
|
+
* strictly. The property is the page's, else the one the ingest stamps for a single-property key.
|
|
29
|
+
* URLs keep campaign parameters only. Never cached: the next page asks the API again.
|
|
30
|
+
*/
|
|
31
|
+
export const BUILT_IN = Object.freeze({
|
|
32
|
+
enabled: true,
|
|
33
|
+
queryParamAllowlist: Object.freeze([]),
|
|
34
|
+
});
|
|
35
|
+
|
|
24
36
|
/**
|
|
25
37
|
* Whether a tracking plan lets the track event `name` through. Segment's `plan.track` semantics: an
|
|
26
38
|
* event marked `enabled: false` is dropped at the source, and `__default` covers events the plan
|
|
@@ -218,19 +230,35 @@ export function readSettings(body) {
|
|
|
218
230
|
}
|
|
219
231
|
|
|
220
232
|
/**
|
|
221
|
-
*
|
|
222
|
-
* outage
|
|
233
|
+
* How long a page without cached settings waits for them. The answer is cached at the edge, so one
|
|
234
|
+
* this slow is an outage, and a host that awaits `bootstrap` must never wait on HP for longer.
|
|
235
|
+
*/
|
|
236
|
+
export const SETTINGS_TIMEOUT_MS = 3000;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The settings for `writeKey`: DISABLED when the API says so, null when it could not be asked in
|
|
240
|
+
* time (an outage keeps whatever the cache last held rather than switching a working site off).
|
|
223
241
|
*/
|
|
224
242
|
export async function fetchSettings(
|
|
225
243
|
apiHost,
|
|
226
244
|
writeKey,
|
|
227
245
|
fetchImpl = globalThis.fetch,
|
|
246
|
+
timeoutMs = SETTINGS_TIMEOUT_MS,
|
|
228
247
|
) {
|
|
229
248
|
try {
|
|
230
249
|
const res = await fetchImpl(settingsUrl(apiHost, writeKey), {
|
|
231
250
|
credentials: 'omit',
|
|
232
251
|
headers: { accept: 'application/json' },
|
|
252
|
+
signal: AbortSignal.timeout?.(timeoutMs),
|
|
233
253
|
});
|
|
254
|
+
// An answered refusal is the key's; only an error, a throttle or a timeout is an outage.
|
|
255
|
+
if (
|
|
256
|
+
res.status >= 400 &&
|
|
257
|
+
res.status < 500 &&
|
|
258
|
+
res.status !== 408 &&
|
|
259
|
+
res.status !== 429
|
|
260
|
+
)
|
|
261
|
+
return DISABLED;
|
|
234
262
|
if (!res.ok) return null;
|
|
235
263
|
const body = await res.json();
|
|
236
264
|
return isSettingsAnswer(body) ? readSettings(body) : null;
|
package/src/shape.js
CHANGED
|
@@ -107,6 +107,10 @@ export function realId(value) {
|
|
|
107
107
|
return PLACEHOLDER_IDS.has(text.toLowerCase()) ? null : text;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** An identity token (ADR 0067), or null for anything but a non-empty string. */
|
|
111
|
+
export const tokenOf = (value) =>
|
|
112
|
+
typeof value === 'string' && value !== '' ? value : null;
|
|
113
|
+
|
|
110
114
|
/** A random UUID: event messageIds and the SDK's own device id. */
|
|
111
115
|
export function uuid() {
|
|
112
116
|
if (typeof crypto !== 'undefined' && crypto.randomUUID)
|
package/src/social-proof.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { parseConsentCookie, servingHeaders } from './consent.js';
|
|
9
9
|
import { getCookie } from './context.js';
|
|
10
|
-
import { resolveApiHost } from './settings.js';
|
|
10
|
+
import { resolveApiHost, SETTINGS_TIMEOUT_MS } from './settings.js';
|
|
11
11
|
import { storageName } from './storage-scope.js';
|
|
12
12
|
|
|
13
13
|
const DEFAULT_TTL_MS = 60_000;
|
|
@@ -37,6 +37,7 @@ const chunk = (items, size) => {
|
|
|
37
37
|
* @param {number} [options.ttlMs] How long to reuse an answer. The numbers move on a pipeline
|
|
38
38
|
* schedule of tens of minutes.
|
|
39
39
|
* @param {typeof fetch} [options.fetchImpl]
|
|
40
|
+
* @param {number} [options.timeoutMs] How long to wait for an answer, as for the settings (3 s).
|
|
40
41
|
* @param {() => object|null} [options.getConsent] The visitor's consent state, sent as `hp-consent`
|
|
41
42
|
* like every serving request. Default: read back from the consent cookie the SDK writes; pass
|
|
42
43
|
* `() => analytics.consent` to use the live state instead.
|
|
@@ -48,6 +49,7 @@ export function createSocialProof({
|
|
|
48
49
|
ttlMs = DEFAULT_TTL_MS,
|
|
49
50
|
fetchImpl,
|
|
50
51
|
getConsent,
|
|
52
|
+
timeoutMs = SETTINGS_TIMEOUT_MS,
|
|
51
53
|
}) {
|
|
52
54
|
const base = resolveApiHost(apiHost);
|
|
53
55
|
if (!base)
|
|
@@ -74,6 +76,8 @@ export function createSocialProof({
|
|
|
74
76
|
// No credentials: the answer is the same for every visitor, and a cookie would make it
|
|
75
77
|
// uncacheable.
|
|
76
78
|
credentials: 'omit',
|
|
79
|
+
// A badge is never worth a wait: an API that does not answer in time is no answer.
|
|
80
|
+
signal: AbortSignal.timeout?.(timeoutMs),
|
|
77
81
|
headers: {
|
|
78
82
|
accept: 'application/json',
|
|
79
83
|
authorization: `Bearer ${writeKey}`,
|
package/src/spec-version.js
CHANGED
package/src/storage-scope.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
export const DEFAULT_STORAGE_NAMES = Object.freeze({
|
|
12
12
|
/** Cookie: the visitor's consent decision, for the site's own servers to forward. */
|
|
13
13
|
consent: 'thg_hp_consent',
|
|
14
|
-
/** localStorage, per property: batches waiting for the ingest to answer. */
|
|
14
|
+
/** localStorage, per write key (property before 3.25): batches waiting for the ingest to answer. */
|
|
15
15
|
retry: 'thg_hp_retry',
|
|
16
16
|
/** localStorage per property and session (sessionStorage until a session is bound). */
|
|
17
17
|
sessionSignals: 'thg_hp_session_signals',
|
|
@@ -24,7 +24,7 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
|
|
|
24
24
|
/** localStorage, per write key: the last good remote settings. */
|
|
25
25
|
settings: 'thg_hp_settings',
|
|
26
26
|
/**
|
|
27
|
-
* sessionStorage, per
|
|
27
|
+
* sessionStorage, per write key: events held for an unanswered CMP, carried to the next page.
|
|
28
28
|
* Only with `holdAcrossPages` (3.5).
|
|
29
29
|
*/
|
|
30
30
|
held: 'thg_hp_held',
|
|
@@ -64,6 +64,12 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
|
|
|
64
64
|
* last closed, submitted) and a subscribed flag. Never contact data (onsite).
|
|
65
65
|
*/
|
|
66
66
|
onsite: 'thg_hp_onsite',
|
|
67
|
+
/**
|
|
68
|
+
* localStorage, per property: the campaign tags the visit landed with, when it was last active
|
|
69
|
+
* and its session id once kept, ended by 30 minutes without a page or event. Never a device or
|
|
70
|
+
* person id (3.26).
|
|
71
|
+
*/
|
|
72
|
+
visitCampaign: 'thg_hp_visit_campaign',
|
|
67
73
|
});
|
|
68
74
|
|
|
69
75
|
/**
|
package/src/transport.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. Persisted retry: every batch is written to localStorage before its first attempt and
|
|
7
7
|
* removed only once the ingest has answered for it.
|
|
8
|
-
* 2. Unload delivery: on pagehide the queue goes by sendBeacon, which outlives the document.
|
|
8
|
+
* 2. Unload delivery: on pagehide the queue goes by sendBeacon, which outlives the document. A
|
|
9
|
+
* beacon's answer is never seen, so while the ingest is failing a copy is also persisted.
|
|
9
10
|
* 3. Bounded: queue size, retry age and payload size are capped, so a broken ingest degrades
|
|
10
11
|
* instead of filling the user's disk.
|
|
11
12
|
*/
|
|
@@ -103,6 +104,10 @@ export class Transport {
|
|
|
103
104
|
this.wakers = new Set();
|
|
104
105
|
this.replayAllowed = !this.o.deferReplay;
|
|
105
106
|
this.draining = null;
|
|
107
|
+
/** The last attempt failed, or the host was found unreachable: unload persists what it beacons. */
|
|
108
|
+
this.failing = false;
|
|
109
|
+
/** The ingest has accepted a batch on this page: only a send that fails says otherwise. */
|
|
110
|
+
this.answered = false;
|
|
106
111
|
|
|
107
112
|
if (isBrowser()) {
|
|
108
113
|
// pagehide is the unload, and the beacon the only send sure to outlive it. A hidden tab may
|
|
@@ -115,6 +120,11 @@ export class Transport {
|
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
/** The ingest is taken as down until a send succeeds, unless one already has: see #flushBeacon. */
|
|
124
|
+
markFailing() {
|
|
125
|
+
if (!this.answered) this.failing = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
/** Allow, and start, the replay of persisted batches. Idempotent. */
|
|
119
129
|
replayPersisted() {
|
|
120
130
|
this.replayAllowed = true;
|
|
@@ -292,6 +302,8 @@ export class Transport {
|
|
|
292
302
|
* Unload: every chunk that fits goes by beacon. A chunk the browser refuses (its keepalive
|
|
293
303
|
* quota is shared across the page) is persisted for the next page load AND tried by fetch;
|
|
294
304
|
* a fetch that lands removes it again, and a duplicate is deduplicated on messageId downstream.
|
|
305
|
+
* While the ingest is failing a beaconed chunk is persisted too, since the browser accepting a
|
|
306
|
+
* beacon says nothing of the ingest: the next page replays it, a duplicate if it did land.
|
|
295
307
|
*
|
|
296
308
|
* The fetch builds its own body rather than reusing the beacon's, for the same reason every retry
|
|
297
309
|
* does: see #send.
|
|
@@ -318,7 +330,12 @@ export class Transport {
|
|
|
318
330
|
} catch {
|
|
319
331
|
ok = false;
|
|
320
332
|
}
|
|
321
|
-
if (ok)
|
|
333
|
+
if (ok) {
|
|
334
|
+
// Claimed, so a page restored from the back/forward cache does not replay it itself.
|
|
335
|
+
const id = this.failing ? this.#persist(batch) : null;
|
|
336
|
+
if (id) this.sending.add(id);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
322
339
|
}
|
|
323
340
|
this.#send(batch, 0, this.#persist(batch));
|
|
324
341
|
}
|
|
@@ -346,6 +363,8 @@ export class Transport {
|
|
|
346
363
|
});
|
|
347
364
|
|
|
348
365
|
if (res.ok) {
|
|
366
|
+
this.failing = false;
|
|
367
|
+
this.answered = true;
|
|
349
368
|
this.#settle(persisted);
|
|
350
369
|
// The ingest is answering again: whatever an earlier outage left behind can go now.
|
|
351
370
|
this.#drainPersisted();
|
|
@@ -365,6 +384,7 @@ export class Transport {
|
|
|
365
384
|
}
|
|
366
385
|
throw new Error(`retryable ${res.status}`);
|
|
367
386
|
} catch (err) {
|
|
387
|
+
this.failing = true;
|
|
368
388
|
if (attempt >= this.o.maxRetries) {
|
|
369
389
|
if (persisted) this.sending.delete(persisted);
|
|
370
390
|
const fate = persisted ? 'kept for the next page' : 'dropped';
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.26.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The campaign a visit arrived from: the landing page's `utm_*` tags, as GA4's session source and
|
|
3
|
+
* the warehouse's `profiles.sessions` attribute a session to its landing page view. Judged in the
|
|
4
|
+
* browser, and sent only with the decision request for a form with rules, never logged.
|
|
5
|
+
*
|
|
6
|
+
* One store per property, owned here and written by the client as it starts: localStorage, so every
|
|
7
|
+
* tab of the visit reads the same campaign. The first tagged page of a visit wins; the visit ends,
|
|
8
|
+
* as the SDK's session does, after SESSION_TIMEOUT_MS without a page or an event, or when the session
|
|
9
|
+
* it was kept under is replaced. It holds the tags, when the visit was last active and the session
|
|
10
|
+
* id once one is kept on the device: never a device or person id. Kept before consent as the onsite
|
|
11
|
+
* counters are (ICO, the strictly necessary exception): it is context about this visit, which the
|
|
12
|
+
* site's own forms need on the next page.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { SESSION_TIMEOUT_MS } from './session-id.js';
|
|
16
|
+
import { storageKey, storageName } from './storage-scope.js';
|
|
17
|
+
|
|
18
|
+
/** The longest a tag is read: the control plane's CAMPAIGN_VALUE_MAX, which the server cuts to. */
|
|
19
|
+
const TAG_MAX = 100;
|
|
20
|
+
|
|
21
|
+
export const CAMPAIGN_FIELDS = [
|
|
22
|
+
'source',
|
|
23
|
+
'medium',
|
|
24
|
+
'campaign',
|
|
25
|
+
'content',
|
|
26
|
+
'term',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `{ source, medium, campaign, content, term }` from `context.campaign` (`readCampaign`, which names
|
|
31
|
+
* `utm_campaign` `name`), only the tags present, each cut to TAG_MAX characters; null without a single `utm_*` (a click id alone is
|
|
32
|
+
* not a campaign).
|
|
33
|
+
*
|
|
34
|
+
* @param {Record<string, string> | null | undefined} campaign
|
|
35
|
+
*/
|
|
36
|
+
export function visitCampaign(campaign) {
|
|
37
|
+
if (!campaign || typeof campaign !== 'object') return null;
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const field of CAMPAIGN_FIELDS) {
|
|
40
|
+
const value = campaign[field === 'campaign' ? 'name' : field];
|
|
41
|
+
if (typeof value === 'string' && value)
|
|
42
|
+
out[field] = value.slice(0, TAG_MAX);
|
|
43
|
+
}
|
|
44
|
+
return Object.keys(out).length ? out : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const norm = (v) => String(v).trim().toLowerCase();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `campaign` (a `visitCampaign`) meets every rule `{ field, match: 'is' | 'contains',
|
|
51
|
+
* values }`, any value meeting a rule, in any case. No rules: true. No campaign, or a rule this SDK
|
|
52
|
+
* cannot read: false.
|
|
53
|
+
*/
|
|
54
|
+
export function arrivedFrom(rules, campaign) {
|
|
55
|
+
return (Array.isArray(rules) ? rules : []).every((rule) => {
|
|
56
|
+
const tag = campaign?.[rule?.field];
|
|
57
|
+
if (!CAMPAIGN_FIELDS.includes(rule?.field) || typeof tag !== 'string')
|
|
58
|
+
return false;
|
|
59
|
+
const have = norm(tag);
|
|
60
|
+
return [].concat(rule.values ?? []).some((v) => {
|
|
61
|
+
const want = norm(v);
|
|
62
|
+
return rule.match === 'is'
|
|
63
|
+
? have === want
|
|
64
|
+
: rule.match === 'contains' && want !== '' && have.includes(want);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const keyOf = (propertyId) =>
|
|
70
|
+
storageKey(
|
|
71
|
+
storageName('visitCampaign'),
|
|
72
|
+
propertyId == null ? null : String(propertyId),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What this page kept, by key, for a browser whose localStorage is missing or refused the last
|
|
77
|
+
* write: the campaign then holds for the page, read and expired as the stored one is.
|
|
78
|
+
*/
|
|
79
|
+
const memory = new Map();
|
|
80
|
+
|
|
81
|
+
function stored(key) {
|
|
82
|
+
if (memory.has(key)) return memory.get(key);
|
|
83
|
+
try {
|
|
84
|
+
const kept = JSON.parse(localStorage.getItem(key));
|
|
85
|
+
return kept && typeof kept === 'object' ? kept : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function put(key, value) {
|
|
92
|
+
try {
|
|
93
|
+
if (value) localStorage.setItem(key, JSON.stringify(value));
|
|
94
|
+
else localStorage.removeItem(key);
|
|
95
|
+
memory.delete(key);
|
|
96
|
+
} catch {
|
|
97
|
+
if (value) memory.set(key, value);
|
|
98
|
+
else memory.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The kept visit when it is still this one: active within the timeout, and under this session. */
|
|
103
|
+
function live(kept, sessionId, now) {
|
|
104
|
+
return typeof kept?.tags === 'object' &&
|
|
105
|
+
kept.tags !== null &&
|
|
106
|
+
Number.isFinite(kept.at) &&
|
|
107
|
+
now - kept.at < SESSION_TIMEOUT_MS &&
|
|
108
|
+
!(sessionId && kept.sid && kept.sid !== sessionId)
|
|
109
|
+
? kept
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The campaign this visit arrived from (a `visitCampaign`), or null; reads only. `sessionId` is the
|
|
115
|
+
* session kept on the device (the host's, or the SDK's cookie), null while it lives in memory.
|
|
116
|
+
*
|
|
117
|
+
* @param {string|null|undefined} propertyId
|
|
118
|
+
* @param {{ sessionId?: string|null, now?: number }} [options]
|
|
119
|
+
*/
|
|
120
|
+
export function readVisitCampaign(
|
|
121
|
+
propertyId,
|
|
122
|
+
{ sessionId = null, now = Date.now() } = {},
|
|
123
|
+
) {
|
|
124
|
+
return live(stored(keyOf(propertyId)), sessionId, now)?.tags ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A page or an event of the visit: the kept campaign stays, active from `now`; a visit with none
|
|
129
|
+
* takes `landing`, this page's tags, when it has any. Answers the visit's campaign. `sessionId` is
|
|
130
|
+
* compared with the kept one; it is stored only with `keepSession` (analytics granted).
|
|
131
|
+
*
|
|
132
|
+
* @param {string|null|undefined} propertyId
|
|
133
|
+
* @param {Record<string, string>|null} landing This page's `visitCampaign`, or null.
|
|
134
|
+
* @param {{ sessionId?: string|null, keepSession?: boolean, now?: number }} [options]
|
|
135
|
+
*/
|
|
136
|
+
export function keepVisitCampaign(
|
|
137
|
+
propertyId,
|
|
138
|
+
landing,
|
|
139
|
+
{ sessionId = null, keepSession = false, now = Date.now() } = {},
|
|
140
|
+
) {
|
|
141
|
+
const key = keyOf(propertyId);
|
|
142
|
+
const raw = stored(key);
|
|
143
|
+
const kept = live(raw, sessionId, now);
|
|
144
|
+
const tags = kept?.tags ?? landing ?? null;
|
|
145
|
+
// A session id is added only while analytics is granted, as the session cookie is; one kept
|
|
146
|
+
// under an earlier grant stays while consent is pending, so a new session still ends the visit.
|
|
147
|
+
// Withdrawal clears the store (`clearVisitCampaign`).
|
|
148
|
+
if (tags)
|
|
149
|
+
put(key, {
|
|
150
|
+
tags,
|
|
151
|
+
at: now,
|
|
152
|
+
sid: (keepSession ? sessionId : null) ?? kept?.sid ?? null,
|
|
153
|
+
});
|
|
154
|
+
else if (raw) put(key, null);
|
|
155
|
+
return tags;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Forget every property's visit campaign on this origin: a sign-out, or consent withdrawn. */
|
|
159
|
+
export function clearVisitCampaign() {
|
|
160
|
+
memory.clear();
|
|
161
|
+
try {
|
|
162
|
+
const prefix = `${storageName('visitCampaign')}:`;
|
|
163
|
+
for (const key of Object.keys(localStorage))
|
|
164
|
+
if (key.startsWith(prefix)) localStorage.removeItem(key);
|
|
165
|
+
} catch {
|
|
166
|
+
/* nothing was kept */
|
|
167
|
+
}
|
|
168
|
+
}
|