@thg-agentic/hyper-personalisation-web 3.23.0 → 3.24.1
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 +31 -9
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -1
- package/dist/onsite-render.min.js +13 -9
- package/dist/onsite-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +1 -1
- package/src/ga4.js +3 -4
- package/src/index.js +4 -4
- package/src/onsite/index.js +115 -36
- package/src/onsite/render.js +356 -83
- package/src/onsite/rules.js +16 -3
- package/src/onsite/styles.js +8 -4
- package/src/onsite/wheel.js +53 -5
- package/src/onsite-facade.js +27 -3
- package/src/version.js +1 -1
- package/types/onsite.d.ts +8 -0
package/src/onsite/index.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* here, with its pages, devices, dates and caps. When one fires, `POST /v1/decisions` is asked for
|
|
10
10
|
* that one slot: a held-out visitor, or no decision, shows nothing. One overlay per page view, the
|
|
11
11
|
* highest priority among those that fire within 50 ms; one that cannot show passes to the next, but
|
|
12
|
-
* a held-out visitor sees none; with the
|
|
13
|
-
* none until that long after the last one this session.
|
|
12
|
+
* a held-out visitor sees none, and once one has shown, closing it lets no other in; with the
|
|
13
|
+
* property's `overlay_gap_minutes`, none until that long after the last one this session. A form
|
|
14
|
+
* the site opens on the shopper's click (`manual`) is past the caps, the gap and the allowance. `?hp_debug=onsite` logs why each form did
|
|
14
15
|
* or did not show; `hp.onsite.debug()` returns it. The renderer (./render.js, its own chunk) is
|
|
15
16
|
* fetched only once a trigger fires, alongside the decision request; a page whose forms never fire
|
|
16
17
|
* never downloads it.
|
|
@@ -21,6 +22,7 @@ import { createApi } from './api.js';
|
|
|
21
22
|
import { createCounters } from './counters.js';
|
|
22
23
|
import { loadRender } from './render-loader.js';
|
|
23
24
|
import {
|
|
25
|
+
ASKED,
|
|
24
26
|
blockers,
|
|
25
27
|
byPriority,
|
|
26
28
|
deviceOf,
|
|
@@ -63,6 +65,8 @@ const INERT = {
|
|
|
63
65
|
|
|
64
66
|
/** How long a fired trigger waits for others, so two forms entering together show the higher priority. */
|
|
65
67
|
const FIRE_WINDOW_MS = 50;
|
|
68
|
+
/** The longest a form waits for its first step's pictures before it opens without them. */
|
|
69
|
+
const WARM_MS = 300;
|
|
66
70
|
|
|
67
71
|
const debugParam = () => {
|
|
68
72
|
try {
|
|
@@ -127,7 +131,28 @@ export function startOnsite(client, options = {}) {
|
|
|
127
131
|
reasons: ['not a form this SDK can show'],
|
|
128
132
|
});
|
|
129
133
|
}
|
|
130
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The covering forms on screen or being opened, by slot (a bar and a popup the shopper asked for
|
|
136
|
+
* can both be up), the slots being opened now, and the form that spent this page view's allowance.
|
|
137
|
+
*/
|
|
138
|
+
const covering = new Map();
|
|
139
|
+
const opening = new Map();
|
|
140
|
+
let spent = null;
|
|
141
|
+
/** The pictures warmed on this page view, by every form, so none is fetched twice. */
|
|
142
|
+
const warmed = new Set();
|
|
143
|
+
/**
|
|
144
|
+
* The covering form that keeps `asked` (a form the shopper asked for, or not) off the screen. Any
|
|
145
|
+
* covering form keeps one the page triggered away; one the shopper asked for waits only for a
|
|
146
|
+
* form still being opened, or for a modal actually on screen (an expanded teaser is one), so it
|
|
147
|
+
* opens over a bar or a teaser's tab.
|
|
148
|
+
*/
|
|
149
|
+
const blocker = (asked) => {
|
|
150
|
+
for (const slot of covering.keys()) {
|
|
151
|
+
const handle = mounted.get(slot);
|
|
152
|
+
if (!asked || !handle || handle.modal?.()) return slot;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
};
|
|
131
156
|
let fired = [];
|
|
132
157
|
const gap = Number.isInteger(settings.overlay_gap_minutes)
|
|
133
158
|
? settings.overlay_gap_minutes
|
|
@@ -144,7 +169,7 @@ export function startOnsite(client, options = {}) {
|
|
|
144
169
|
/** Forms whose trigger fired on an empty basket: they try again when the basket changes. */
|
|
145
170
|
const awaitingBasket = new Set();
|
|
146
171
|
/** Covering forms whose trigger fired before the consent banner was answered. */
|
|
147
|
-
const awaitingConsent = new
|
|
172
|
+
const awaitingConsent = new Map();
|
|
148
173
|
|
|
149
174
|
const note = (form, state, reasons = []) => {
|
|
150
175
|
trace.set(form.slot, {
|
|
@@ -187,35 +212,59 @@ export function startOnsite(client, options = {}) {
|
|
|
187
212
|
stops.delete(form.slot);
|
|
188
213
|
};
|
|
189
214
|
|
|
190
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Show `form` if its rules allow it; resolves to the state its trace records. A form on screen is
|
|
217
|
+
* not mounted again, and a second call while it is being opened (a double click during the
|
|
218
|
+
* decision) shares the first one's answer.
|
|
219
|
+
*/
|
|
220
|
+
function show(form, reason, kept = false, asked = ASKED(form)) {
|
|
221
|
+
if (mounted.has(form.slot)) return Promise.resolve('shown');
|
|
222
|
+
if (!opening.has(form.slot))
|
|
223
|
+
opening.set(
|
|
224
|
+
form.slot,
|
|
225
|
+
attempt(form, reason, kept, asked).finally(() =>
|
|
226
|
+
opening.delete(form.slot),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
return opening.get(form.slot);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `kept`: a teaser's tab carried from an earlier page, shown again whatever its caps. `asked`: the
|
|
234
|
+
* shopper asked for it (a `manual` form, or one opened by an alias), so its counters do not apply.
|
|
235
|
+
*/
|
|
236
|
+
async function attempt(form, reason, kept, asked) {
|
|
193
237
|
if (suppressed()) {
|
|
194
238
|
return note(form, 'not shown', [
|
|
195
239
|
`suppressed: ${[...window[SUPPRESSED]].join(', ')}`,
|
|
196
240
|
]);
|
|
197
241
|
}
|
|
198
|
-
const reasons = blockers(
|
|
199
|
-
form,
|
|
200
|
-
kept ? {
|
|
201
|
-
|
|
242
|
+
const reasons = blockers(form, {
|
|
243
|
+
...env(form),
|
|
244
|
+
...(kept ? { seen: INERT.seen() } : {}),
|
|
245
|
+
asked,
|
|
246
|
+
});
|
|
202
247
|
if (reasons.length) {
|
|
203
248
|
return note(form, 'not eligible', reasons);
|
|
204
249
|
}
|
|
205
250
|
// The gap first: it holds for the whole session, so it is the reason worth reading.
|
|
206
251
|
const interrupts = INTERRUPTS(form.format) && !kept;
|
|
207
|
-
const wait = interrupts && gapHolds();
|
|
252
|
+
const wait = interrupts && !asked && gapHolds();
|
|
208
253
|
if (wait) {
|
|
209
254
|
return note(form, 'not shown', [wait]);
|
|
210
255
|
}
|
|
211
256
|
// Never over an unanswered consent banner: the shopper answers it first, whatever the answer.
|
|
212
257
|
if (interrupts && !access.preview && access.consentAnswered?.() === false) {
|
|
213
|
-
awaitingConsent.
|
|
258
|
+
awaitingConsent.set(form, asked);
|
|
214
259
|
return note(form, 'not shown', ['waiting for the consent banner']);
|
|
215
260
|
}
|
|
216
261
|
const covers = OVERLAY(form.format);
|
|
217
|
-
|
|
218
|
-
|
|
262
|
+
const shown = covers && blocker(asked);
|
|
263
|
+
if (shown) return note(form, 'not shown', [`${shown} is showing`]);
|
|
264
|
+
if (covers && spent && !asked) {
|
|
265
|
+
return note(form, 'not shown', [
|
|
266
|
+
`${spent} already showed on this page view`,
|
|
267
|
+
]);
|
|
219
268
|
}
|
|
220
269
|
let container = null;
|
|
221
270
|
if (!covers) {
|
|
@@ -228,7 +277,7 @@ export function startOnsite(client, options = {}) {
|
|
|
228
277
|
return note(form, 'not shown', ['no element matches its selector']);
|
|
229
278
|
}
|
|
230
279
|
}
|
|
231
|
-
if (covers)
|
|
280
|
+
if (covers) covering.set(form.slot, form);
|
|
232
281
|
unwatch(form);
|
|
233
282
|
note(form, 'asking for a decision', [reason]);
|
|
234
283
|
// The renderer is fetched while the decision is asked, so neither waits on the other.
|
|
@@ -265,16 +314,26 @@ export function startOnsite(client, options = {}) {
|
|
|
265
314
|
!(basket > 0) &&
|
|
266
315
|
'the basket is empty');
|
|
267
316
|
if (missing) {
|
|
268
|
-
|
|
317
|
+
covering.delete(form.slot);
|
|
269
318
|
if (missing === 'the basket is empty') awaitingBasket.add(form);
|
|
270
319
|
return note(form, 'not shown', [missing]);
|
|
271
320
|
}
|
|
272
321
|
awaitingBasket.delete(form);
|
|
273
322
|
if (!creative) {
|
|
274
|
-
// A held-out visitor whose trigger fired is exposed to the default, as a treated one is
|
|
323
|
+
// A held-out visitor whose trigger fired is exposed to the default, as a treated one is,
|
|
324
|
+
// and its page stays one with no popup: the allowance is spent as a shown one spends it.
|
|
275
325
|
if (decision?.holdout === true && access.analytics())
|
|
276
326
|
client.recordExposure(res.body);
|
|
277
|
-
|
|
327
|
+
covering.delete(form.slot);
|
|
328
|
+
if (covers && decision?.holdout === true) spent ??= form.slot;
|
|
329
|
+
// The site shows a held-out visitor what it shows without HP, at the same moment; a form
|
|
330
|
+
// the site opened hears so from `open`, which resolves false.
|
|
331
|
+
if (decision?.holdout === true && !asked)
|
|
332
|
+
emit('held', {
|
|
333
|
+
slot: form.slot,
|
|
334
|
+
arm: decision.arm ?? null,
|
|
335
|
+
format: form.format,
|
|
336
|
+
});
|
|
278
337
|
return note(
|
|
279
338
|
form,
|
|
280
339
|
decision?.holdout === true ? 'held out' : 'no decision',
|
|
@@ -287,11 +346,22 @@ export function startOnsite(client, options = {}) {
|
|
|
287
346
|
}
|
|
288
347
|
const render = await renderer;
|
|
289
348
|
if (!render) {
|
|
290
|
-
|
|
349
|
+
covering.delete(form.slot);
|
|
291
350
|
return note(form, 'not shown', ['the renderer did not load']);
|
|
292
351
|
}
|
|
352
|
+
// Its first step's pictures fetched and decoded first, so it never opens on an empty one, and
|
|
353
|
+
// never waits for them longer than WARM_MS.
|
|
354
|
+
await Promise.race([
|
|
355
|
+
render.warm?.(form, creative, creative.steps[0].id, warmed),
|
|
356
|
+
new Promise((resolve) => setTimeout(resolve, WARM_MS)),
|
|
357
|
+
]);
|
|
358
|
+
if (suppressed()) {
|
|
359
|
+
covering.delete(form.slot);
|
|
360
|
+
return note(form, 'not shown', ['suppressed']);
|
|
361
|
+
}
|
|
293
362
|
if (!kept) counters.shown(form.slot);
|
|
294
363
|
if (interrupts) counters.covered();
|
|
364
|
+
if (covers) spent ??= form.slot;
|
|
295
365
|
const tell = (name, detail = {}) =>
|
|
296
366
|
emit(name, {
|
|
297
367
|
slot: form.slot,
|
|
@@ -310,12 +380,13 @@ export function startOnsite(client, options = {}) {
|
|
|
310
380
|
loadWheel,
|
|
311
381
|
loadAnswers,
|
|
312
382
|
container,
|
|
383
|
+
warmed,
|
|
313
384
|
coupon,
|
|
314
385
|
basket: () => access.basketValue?.() ?? null,
|
|
315
386
|
emit: tell,
|
|
316
387
|
onClose(why) {
|
|
317
388
|
mounted.delete(form.slot);
|
|
318
|
-
|
|
389
|
+
covering.delete(form.slot);
|
|
319
390
|
note(form, 'closed', [why]);
|
|
320
391
|
tell('closed', { reason: why });
|
|
321
392
|
},
|
|
@@ -331,15 +402,15 @@ export function startOnsite(client, options = {}) {
|
|
|
331
402
|
* Triggers that fire within FIRE_WINDOW_MS of each other compete: only the highest priority shows.
|
|
332
403
|
* One that cannot show passes to the next; a held-out visitor sees none, so the holdout stays clean.
|
|
333
404
|
*/
|
|
334
|
-
function fire(form, reason) {
|
|
335
|
-
if (!OVERLAY(form.format)) return show(form, reason);
|
|
336
|
-
fired.push([form, reason]);
|
|
405
|
+
function fire(form, reason, asked = ASKED(form)) {
|
|
406
|
+
if (!OVERLAY(form.format)) return show(form, reason, false, asked);
|
|
407
|
+
fired.push([form, reason, asked]);
|
|
337
408
|
if (fired.length > 1) return;
|
|
338
409
|
setTimeout(async () => {
|
|
339
410
|
const queue = fired.sort(([a], [b]) => byPriority(a, b));
|
|
340
411
|
fired = [];
|
|
341
|
-
for (const [i, [next, why]] of queue.entries()) {
|
|
342
|
-
const state = await show(next, why);
|
|
412
|
+
for (const [i, [next, why, ask]] of queue.entries()) {
|
|
413
|
+
const state = await show(next, why, false, ask);
|
|
343
414
|
if (state === 'shown' || state === 'held out') {
|
|
344
415
|
for (const [f] of queue.slice(i + 1))
|
|
345
416
|
note(f, 'not shown', [`${next.slot} has a higher priority`]);
|
|
@@ -390,9 +461,9 @@ export function startOnsite(client, options = {}) {
|
|
|
390
461
|
const stopConsent = access.onConsentAnswered?.(() => {
|
|
391
462
|
const waiting = [...awaitingConsent];
|
|
392
463
|
awaitingConsent.clear();
|
|
393
|
-
for (const form of waiting)
|
|
464
|
+
for (const [form, asked] of waiting)
|
|
394
465
|
if (!mounted.has(form.slot))
|
|
395
|
-
|
|
466
|
+
asked ? fire(form, 'manual', asked) : arm(form);
|
|
396
467
|
});
|
|
397
468
|
|
|
398
469
|
const stopBasket = access.onBasket?.(() => {
|
|
@@ -406,20 +477,28 @@ export function startOnsite(client, options = {}) {
|
|
|
406
477
|
});
|
|
407
478
|
|
|
408
479
|
const controller = Object.freeze({
|
|
409
|
-
/**
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
480
|
+
/**
|
|
481
|
+
* Show the form keyed `name`, else the first whose `aliases` has it (the site's own sign-up
|
|
482
|
+
* button names one), now, whatever its trigger. From there it is that form, by its slot.
|
|
483
|
+
*/
|
|
484
|
+
open(name) {
|
|
485
|
+
const keyed = forms.find((f) => f.slot === name);
|
|
486
|
+
const form = keyed ?? forms.find((f) => f.aliases.includes(name));
|
|
487
|
+
if (!form) return Promise.resolve(false);
|
|
488
|
+
// One already on screen (an inline form) is shown, not mounted twice. A form opened by an
|
|
489
|
+
// alias is the shopper asking, as a `manual` one is.
|
|
490
|
+
return mounted.has(form.slot)
|
|
491
|
+
? Promise.resolve(true)
|
|
492
|
+
: show(form, 'manual', false, ASKED(form) || !keyed).then(
|
|
493
|
+
(state) => state === 'shown',
|
|
494
|
+
);
|
|
416
495
|
},
|
|
417
496
|
/** `hp.onsite.suppress`: take every form off the screen, quietly; none shows until unsuppressed. */
|
|
418
497
|
suppress() {
|
|
419
498
|
for (const [slot, handle] of mounted) {
|
|
420
499
|
handle.destroy();
|
|
421
500
|
mounted.delete(slot);
|
|
422
|
-
|
|
501
|
+
covering.delete(slot);
|
|
423
502
|
const form = forms.find((f) => f.slot === slot);
|
|
424
503
|
if (form) note(form, 'closed', ['suppressed']);
|
|
425
504
|
}
|