@thg-agentic/hyper-personalisation-web 3.24.0 → 3.25.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 +46 -16
- package/dist/hp.min.js +2 -2
- package/dist/onsite-render.min.js +3 -3
- package/dist/onsite-wheel.min.js +1 -1
- package/package.json +1 -1
- package/src/bootstrap.js +59 -30
- package/src/consent-gate.js +8 -5
- package/src/context.js +2 -1
- package/src/device-storage.js +4 -4
- package/src/index.js +68 -7
- package/src/onsite/render.js +49 -18
- package/src/onsite/styles.js +1 -1
- package/src/onsite/wheel.js +61 -13
- package/src/settings.js +30 -2
- package/src/social-proof.js +5 -1
- package/src/storage-scope.js +2 -2
- package/src/transport.js +22 -2
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +3 -2
- package/types/hp.d.ts +3 -2
- package/types/index.d.ts +4 -3
package/src/onsite/render.js
CHANGED
|
@@ -56,6 +56,8 @@ const COPY = {
|
|
|
56
56
|
claimed: 'This email address has already claimed its code.',
|
|
57
57
|
copied: 'Copied',
|
|
58
58
|
thanks: 'Thanks, you’re signed up',
|
|
59
|
+
retry: 'You’re signed up, but we couldn’t load your prize. Please try again.',
|
|
60
|
+
unclaimed: 'We couldn’t load your prize right now.',
|
|
59
61
|
unavailable: 'Not available right now.',
|
|
60
62
|
won: 'You won',
|
|
61
63
|
};
|
|
@@ -404,6 +406,9 @@ export function mountForm(o) {
|
|
|
404
406
|
warmed: o.warmed ?? new Set(),
|
|
405
407
|
warming: false,
|
|
406
408
|
owed: false,
|
|
409
|
+
// Whether the last claim failed (said where the code would be), and how many spins it failed.
|
|
410
|
+
unclaimed: false,
|
|
411
|
+
misses: 0,
|
|
407
412
|
submitted: false,
|
|
408
413
|
pow: null,
|
|
409
414
|
wheel: null,
|
|
@@ -651,9 +656,19 @@ export function mountForm(o) {
|
|
|
651
656
|
function fillCodes() {
|
|
652
657
|
for (const box of state.codes) {
|
|
653
658
|
box.replaceChildren();
|
|
654
|
-
if (!state.claim)
|
|
659
|
+
if (!state.claim) {
|
|
660
|
+
if (state.unclaimed) box.append(el('p', '', COPY.unclaimed));
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
655
663
|
if (!state.claim.code) {
|
|
656
|
-
|
|
664
|
+
// A code still to come only when every pool was out (`fallback`); a prize with no pool
|
|
665
|
+
// ("Try again") gives none, so it says its own words and nothing about a code.
|
|
666
|
+
const said = state.claim.held
|
|
667
|
+
? COPY.claimed
|
|
668
|
+
: state.claim.fallback === true
|
|
669
|
+
? COPY.code
|
|
670
|
+
: state.claim.prize?.label;
|
|
671
|
+
if (said) box.append(el('p', '', said));
|
|
657
672
|
continue;
|
|
658
673
|
}
|
|
659
674
|
// The prize the code is for, beside it: "You won!" alone does not say what.
|
|
@@ -1206,6 +1221,7 @@ export function mountForm(o) {
|
|
|
1206
1221
|
if (channel === 'email')
|
|
1207
1222
|
state.signedUp = { email: value, email_receipt: state.receipt };
|
|
1208
1223
|
state.submitted = true;
|
|
1224
|
+
// Subscribed and done with this form, whatever its claim does: a shopper is never asked twice.
|
|
1209
1225
|
counters.submitted(form.slot);
|
|
1210
1226
|
engaged('submit', step.id);
|
|
1211
1227
|
// A form with prizes claims its code now, whether or not the shopper goes on to spin, so the
|
|
@@ -1246,27 +1262,36 @@ export function mountForm(o) {
|
|
|
1246
1262
|
* The creative's step `id`, else the SDK's own plain one: a thanks with no prize (`fallback`); a
|
|
1247
1263
|
* code this browser claimed on another form, with that code (`held`); or, for an address another
|
|
1248
1264
|
* sign-up claimed with (`held`, no code), a welcome back. That code is never shown here: the
|
|
1249
|
-
* sign-up hands it to the destinations again, so the inbox has it when one carries it.
|
|
1265
|
+
* sign-up hands it to the destinations again, so the inbox has it when one carries it. Or a claim
|
|
1266
|
+
* that kept failing (`unclaimed`): the sign-up stands and the prize could not be loaded, so it
|
|
1267
|
+
* thanks the shopper and says so, as Klaviyo's and Privy's wheels do, rather than an error.
|
|
1250
1268
|
*/
|
|
1251
1269
|
function ownStep(id) {
|
|
1252
1270
|
if (creative.steps.some((s) => s.id === id)) return id;
|
|
1271
|
+
const shop = { t: 'button', label: COPY.shop, action: 'close' };
|
|
1253
1272
|
const blocks =
|
|
1254
|
-
id === '
|
|
1273
|
+
id === 'unclaimed'
|
|
1255
1274
|
? [
|
|
1256
|
-
{
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
},
|
|
1260
|
-
{ t: 'code' },
|
|
1275
|
+
{ t: 'heading', text: COPY.thanks },
|
|
1276
|
+
{ t: 'text', text: COPY.unclaimed },
|
|
1277
|
+
shop,
|
|
1261
1278
|
]
|
|
1262
|
-
:
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1279
|
+
: id === 'fallback' || state.claim?.code
|
|
1280
|
+
? [
|
|
1281
|
+
{
|
|
1282
|
+
t: 'heading',
|
|
1283
|
+
text: id === 'fallback' ? COPY.thanks : COPY.yours,
|
|
1284
|
+
},
|
|
1285
|
+
{ t: 'code' },
|
|
1286
|
+
]
|
|
1287
|
+
: [
|
|
1288
|
+
{ t: 'heading', text: COPY.held },
|
|
1289
|
+
{
|
|
1290
|
+
t: 'text',
|
|
1291
|
+
text: form.code_relayed === true ? COPY.resent : COPY.inbox,
|
|
1292
|
+
},
|
|
1293
|
+
shop,
|
|
1294
|
+
];
|
|
1270
1295
|
creative.steps.push({ id, blocks });
|
|
1271
1296
|
return id;
|
|
1272
1297
|
}
|
|
@@ -1284,6 +1309,7 @@ export function mountForm(o) {
|
|
|
1284
1309
|
function claim(retries = CLAIM_RETRY_MS) {
|
|
1285
1310
|
const receipt = state.receipt;
|
|
1286
1311
|
state.claiming ??= (async () => {
|
|
1312
|
+
state.unclaimed = false;
|
|
1287
1313
|
let res;
|
|
1288
1314
|
for (const ms of [0, ...retries]) {
|
|
1289
1315
|
if (ms) await new Promise((r) => setTimeout(r, ms));
|
|
@@ -1302,6 +1328,8 @@ export function mountForm(o) {
|
|
|
1302
1328
|
// the page; any other failure is final until a spin asks again.
|
|
1303
1329
|
if (!retries.length && res.status === 503) return claim();
|
|
1304
1330
|
settled(null);
|
|
1331
|
+
state.unclaimed = true;
|
|
1332
|
+
if (!state.closed) fillCodes();
|
|
1305
1333
|
return null;
|
|
1306
1334
|
}
|
|
1307
1335
|
// The browser that claimed the code proved it is the shopper's when it won it: a held claim
|
|
@@ -1322,9 +1350,12 @@ export function mountForm(o) {
|
|
|
1322
1350
|
busy(true);
|
|
1323
1351
|
const won = await claim();
|
|
1324
1352
|
if (state.closed) return;
|
|
1353
|
+
// The sign-up stands whatever the claim did: a failed one is offered once more, then the
|
|
1354
|
+
// shopper is thanked and told the prize could not be loaded, never left on an error.
|
|
1325
1355
|
if (!won) {
|
|
1326
1356
|
busy(false);
|
|
1327
|
-
|
|
1357
|
+
state.misses += 1;
|
|
1358
|
+
return state.misses < 2 ? say(COPY.retry) : show(ownStep('unclaimed'));
|
|
1328
1359
|
}
|
|
1329
1360
|
const fallback = won.fallback === true && !won.code;
|
|
1330
1361
|
// A code the address claimed on another form is that form's prize: the wheel lands only on
|
package/src/onsite/styles.js
CHANGED
|
@@ -145,7 +145,7 @@ hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--ln)}
|
|
|
145
145
|
.hd{display:none}
|
|
146
146
|
.uc h2{text-transform:uppercase;font-weight:800;letter-spacing:-.01em;line-height:1.02}
|
|
147
147
|
.uc .btn:not(.no),.uc .tb{text-transform:uppercase;letter-spacing:.06em}
|
|
148
|
-
.sheet,.bar,.teaser{position:fixed;inset:auto;z-index:2147483646;display:flex;flex-direction:column}
|
|
148
|
+
.sheet,.bar,.teaser{position:fixed;inset:auto;height:auto;z-index:2147483646;display:flex;flex-direction:column}
|
|
149
149
|
.sheet{right:24px;bottom:24px;width:min(400px,calc(100% - 32px));max-height:calc(100% - 48px)}
|
|
150
150
|
.sheet.p-bottom-left{right:auto;left:24px}
|
|
151
151
|
.sheet.p-bottom{right:auto;left:50%;translate:-50% 0}
|
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/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/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',
|
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.25.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/types/bootstrap.d.ts
CHANGED
|
@@ -165,8 +165,9 @@ export interface BootstrappedClient extends AnalyticsClient {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
|
-
* The client, or null when the key is not enabled
|
|
169
|
-
* visit
|
|
168
|
+
* The client, or null when the key is not enabled or the options are invalid. Settings that cannot
|
|
169
|
+
* be fetched on a first visit start it on built-in, collect-only defaults (3.25). Never rejects.
|
|
170
|
+
* Idempotent per page.
|
|
170
171
|
*/
|
|
171
172
|
export declare function bootstrap(
|
|
172
173
|
config: HpConfig,
|
package/types/hp.d.ts
CHANGED
|
@@ -35,8 +35,9 @@ interface HpTrack {
|
|
|
35
35
|
/**
|
|
36
36
|
* The page's client, callable from anywhere: calls made before `bootstrap` has started it wait and
|
|
37
37
|
* replay in order, keeping the messageId and time they were made with; on a page that turns out to
|
|
38
|
-
* be off they are dropped. A page that navigates first
|
|
39
|
-
*
|
|
38
|
+
* be off they are dropped. A page that navigates first carries them to the next page, which sends
|
|
39
|
+
* them under its own consent (3.9); one that cannot reach the API starts on built-in settings and
|
|
40
|
+
* sends them itself (3.25). Nothing is returned (but by
|
|
40
41
|
* `assignmentDecisionId` and `recommendationContext`), nothing is awaited, and nothing ever throws
|
|
41
42
|
* into the caller.
|
|
42
43
|
*/
|
package/types/index.d.ts
CHANGED
|
@@ -119,7 +119,8 @@ export interface AnalyticsOptions extends AnalyticsContextSources {
|
|
|
119
119
|
apiHost?: string;
|
|
120
120
|
/** Default 'web'. */
|
|
121
121
|
writeKey?: string;
|
|
122
|
-
|
|
122
|
+
/** null: none on the wire, and the ingest stamps the key's (a key scoped to one property). */
|
|
123
|
+
propertyId: string | number | null;
|
|
123
124
|
channel?: string | null;
|
|
124
125
|
market?: string | null;
|
|
125
126
|
environment?: string | null;
|
|
@@ -200,7 +201,7 @@ export interface AnalyticsOptions extends AnalyticsContextSources {
|
|
|
200
201
|
mintAnonymousId?: boolean;
|
|
201
202
|
/**
|
|
202
203
|
* Strict consent: carry events held for an unanswered CMP across a navigation, in sessionStorage
|
|
203
|
-
* per
|
|
204
|
+
* per write key (`thg_hp_held`), so a redirect before the answer (a sign-in) does not lose them.
|
|
204
205
|
* They leave only on a grant, on whichever page it comes, and are deleted on a refusal or after
|
|
205
206
|
* 30 minutes. This writes event data to the device before the visitor has answered: enable it
|
|
206
207
|
* only where your consent policy allows that. With bootstrap it also lets `hp` carry calls made
|
|
@@ -466,7 +467,7 @@ export interface StorageNames {
|
|
|
466
467
|
decisions?: string;
|
|
467
468
|
/** localStorage. Default 'thg_hp_settings'. */
|
|
468
469
|
settings?: string;
|
|
469
|
-
/** sessionStorage, with holdAcrossPages. Default 'thg_hp_held'. */
|
|
470
|
+
/** sessionStorage, per write key, with holdAcrossPages. Default 'thg_hp_held'. */
|
|
470
471
|
held?: string;
|
|
471
472
|
/** sessionStorage: `hp` calls carried to the next page (3.9). Default 'thg_hp_carried'. */
|
|
472
473
|
carried?: string;
|