@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.
@@ -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 property's `overlay_gap_minutes`,
13
- * none until that long after the last one this session. `?hp_debug=onsite` logs why each form did
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
- let overlay = null;
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 Set();
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
- /** `kept`: a teaser's tab carried from an earlier page, shown again whatever its caps. */
191
- /** Show `form` if its rules allow it; resolves to the state its trace records. */
192
- async function show(form, reason, kept = false) {
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 ? { ...env(form), seen: INERT.seen() } : env(form),
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.add(form);
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
- if (covers && overlay) {
218
- return note(form, 'not shown', [`${overlay} is showing`]);
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) overlay = form.slot;
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
- if (covers) overlay = null;
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
- if (covers) overlay = null;
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
- if (covers) overlay = null;
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
- if (overlay === form.slot) overlay = null;
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
- form.trigger.type === 'manual' ? fire(form, 'manual') : arm(form);
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
- /** Show the form on `slot` now, whatever its trigger, if its rules allow it. */
410
- open(slot) {
411
- const form = forms.find((f) => f.slot === slot);
412
- // One already on screen (an inline form) is shown, not mounted twice.
413
- return form && !mounted.has(slot)
414
- ? show(form, 'manual').then((state) => state === 'shown')
415
- : Promise.resolve(mounted.has(slot));
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
- if (overlay === slot) overlay = null;
501
+ covering.delete(slot);
423
502
  const form = forms.find((f) => f.slot === slot);
424
503
  if (form) note(form, 'closed', ['suppressed']);
425
504
  }