@thg-agentic/hyper-personalisation-web 3.28.1 → 3.30.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 +13 -5
- package/dist/hp.min.js +2 -2
- package/dist/onsite-answers.min.js +15 -1
- package/dist/onsite-products.min.js +9 -3
- package/dist/onsite-render.min.js +5 -6
- package/dist/onsite-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +2 -2
- package/src/index.js +2 -0
- package/src/onsite/answers.js +84 -2
- package/src/onsite/api.js +16 -1
- package/src/onsite/countdown.js +35 -0
- package/src/onsite/index.js +238 -102
- package/src/onsite/preview.js +3 -2
- package/src/onsite/products.js +78 -21
- package/src/onsite/render.js +180 -84
- package/src/onsite/rules.js +35 -8
- package/src/onsite/styles.js +0 -1
- package/src/onsite-facade.js +8 -4
- package/src/spec-version.js +1 -1
- package/src/version.js +1 -1
- package/types/events.d.ts +3 -2
- package/types/onsite.d.ts +50 -1
package/src/onsite/index.js
CHANGED
|
@@ -65,6 +65,28 @@ const INERT = {
|
|
|
65
65
|
code: () => null,
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The codes a fired form says `nothing` with: HP has nothing to show for it, rather than its rules
|
|
70
|
+
* (caps, the gap, the site's suppression, another form showing) keeping it away.
|
|
71
|
+
*/
|
|
72
|
+
const NOTHING = new Set([
|
|
73
|
+
'decision-failed',
|
|
74
|
+
'no-creative',
|
|
75
|
+
'no-code',
|
|
76
|
+
'renderer-failed',
|
|
77
|
+
'products-failed',
|
|
78
|
+
'error',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The longest from the end of the fire window to a form's decision, renderer and products: past it
|
|
83
|
+
* the form shows nothing, and an answer that comes later is never used. Forms that fire together
|
|
84
|
+
* share one, so `nothing` comes at most FIRE_WINDOW_MS + DEADLINE_MS after the trigger.
|
|
85
|
+
*/
|
|
86
|
+
const DEADLINE_MS = 3000;
|
|
87
|
+
/** A decision that did not come in time, as one the network failed. */
|
|
88
|
+
const LATE = { status: 0, body: null };
|
|
89
|
+
|
|
68
90
|
/** The longest a limited form waits for the evaluator and the datafile before it is decided without. */
|
|
69
91
|
const CLAIMS_MS = 1500;
|
|
70
92
|
/** How long a fired trigger waits for others, so two forms entering together show the higher priority. */
|
|
@@ -148,6 +170,7 @@ export function startOnsite(client, options = {}) {
|
|
|
148
170
|
trace.set(String(raw?.slot ?? `form ${trace.size + 1}`), {
|
|
149
171
|
slot: String(raw?.slot ?? ''),
|
|
150
172
|
state: 'invalid',
|
|
173
|
+
code: 'invalid',
|
|
151
174
|
reasons: ['not a form this SDK can show'],
|
|
152
175
|
});
|
|
153
176
|
}
|
|
@@ -170,6 +193,7 @@ export function startOnsite(client, options = {}) {
|
|
|
170
193
|
const covering = new Map();
|
|
171
194
|
const opening = new Map();
|
|
172
195
|
let spent = null;
|
|
196
|
+
let stopped = false;
|
|
173
197
|
/** The pictures warmed on this page view, by every form, so none is fetched twice. */
|
|
174
198
|
const warmed = new Set();
|
|
175
199
|
/**
|
|
@@ -185,7 +209,10 @@ export function startOnsite(client, options = {}) {
|
|
|
185
209
|
}
|
|
186
210
|
return null;
|
|
187
211
|
};
|
|
188
|
-
|
|
212
|
+
/** The covering forms whose triggers fired in this window, and the deadline they share. */
|
|
213
|
+
let batch = null;
|
|
214
|
+
/** Every window still collecting or being decided: a form the site opens is taken from each. */
|
|
215
|
+
const windows = new Set();
|
|
189
216
|
const gap = Number.isInteger(settings.overlay_gap_minutes)
|
|
190
217
|
? settings.overlay_gap_minutes
|
|
191
218
|
: 0;
|
|
@@ -203,20 +230,51 @@ export function startOnsite(client, options = {}) {
|
|
|
203
230
|
/** Covering forms whose trigger fired before the consent banner was answered. */
|
|
204
231
|
const awaitingConsent = new Map();
|
|
205
232
|
|
|
206
|
-
const note = (form, state, reasons = []) => {
|
|
233
|
+
const note = (form, state, code, reasons = []) => {
|
|
207
234
|
trace.set(form.slot, {
|
|
208
235
|
slot: form.slot,
|
|
209
236
|
format: form.format,
|
|
210
237
|
trigger: form.trigger.type,
|
|
211
238
|
priority: form.priority,
|
|
212
239
|
state,
|
|
240
|
+
code,
|
|
213
241
|
reasons,
|
|
214
242
|
});
|
|
215
243
|
if (logging)
|
|
216
244
|
console.info(
|
|
217
245
|
`[hp] onsite ${form.slot}: ${state}${reasons.length ? ` (${reasons.join('; ')})` : ''}`,
|
|
218
246
|
);
|
|
219
|
-
return state;
|
|
247
|
+
return { state, code };
|
|
248
|
+
};
|
|
249
|
+
/** A form that was being opened gives up: it covers nothing any more. */
|
|
250
|
+
const drop = (form, state, code, reasons) => {
|
|
251
|
+
covering.delete(form.slot);
|
|
252
|
+
return note(form, state, code, reasons);
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* `promise`, or `late` once `deadline` has passed: an answer read after it (a busy main thread
|
|
256
|
+
* runs the timer late) is dropped too.
|
|
257
|
+
*/
|
|
258
|
+
const within = (promise, deadline, late) => {
|
|
259
|
+
let id;
|
|
260
|
+
return Promise.race([
|
|
261
|
+
promise,
|
|
262
|
+
new Promise((resolve) => {
|
|
263
|
+
id = setTimeout(resolve, deadline - Date.now(), late);
|
|
264
|
+
}),
|
|
265
|
+
])
|
|
266
|
+
.then((value) => (Date.now() > deadline ? late : value))
|
|
267
|
+
.finally(() => clearTimeout(id));
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Tell the page HP shows nothing for `form`: the site shows its own popup in its place, so the
|
|
271
|
+
* page view's allowance is spent, as a held-out visitor's is, and no other HP form follows it.
|
|
272
|
+
*/
|
|
273
|
+
const say = (form, code) => {
|
|
274
|
+
// A page suppressed while the form was decided keeps its own screen clear: nothing to fill.
|
|
275
|
+
if (stopped || suppressed()) return;
|
|
276
|
+
spent ??= form.slot;
|
|
277
|
+
emit('nothing', { slot: form.slot, format: form.format, code });
|
|
220
278
|
};
|
|
221
279
|
|
|
222
280
|
const sessionAudiences = () => {
|
|
@@ -246,29 +304,51 @@ export function startOnsite(client, options = {}) {
|
|
|
246
304
|
};
|
|
247
305
|
|
|
248
306
|
/**
|
|
249
|
-
* Show `form` if its rules allow it; resolves to the state its trace records
|
|
250
|
-
*
|
|
251
|
-
* decision
|
|
307
|
+
* Show `form` if its rules allow it; resolves to the `{ state, code }` its trace records, and
|
|
308
|
+
* whether the site asked for or opened it (`asked`). A form on screen is not mounted again, and a second
|
|
309
|
+
* call while it is being opened (a double click during the decision, or the site opening a form
|
|
310
|
+
* whose trigger just fired) shares the first one's answer. A throw anywhere in it is the code
|
|
311
|
+
* `error`.
|
|
252
312
|
*/
|
|
253
|
-
function show(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
);
|
|
262
|
-
|
|
313
|
+
function show(
|
|
314
|
+
form,
|
|
315
|
+
reason,
|
|
316
|
+
kept = false,
|
|
317
|
+
asked = ASKED(form),
|
|
318
|
+
deadline = Date.now() + DEADLINE_MS,
|
|
319
|
+
) {
|
|
320
|
+
if (mounted.has(form.slot))
|
|
321
|
+
return Promise.resolve({ state: 'shown', code: 'shown', asked });
|
|
322
|
+
let entry = opening.get(form.slot);
|
|
323
|
+
if (entry) {
|
|
324
|
+
entry.asked ||= asked;
|
|
325
|
+
return entry.done;
|
|
326
|
+
}
|
|
327
|
+
entry = { asked };
|
|
328
|
+
entry.done = attempt(form, reason, kept, asked, deadline)
|
|
329
|
+
.catch((err) => {
|
|
330
|
+
if (mounted.has(form.slot)) return { state: 'shown', code: 'shown' };
|
|
331
|
+
console.warn('[hp] onsite form failed:', err?.message ?? err);
|
|
332
|
+
return drop(form, 'not shown', 'error', [
|
|
333
|
+
`failed: ${err?.message ?? err}`,
|
|
334
|
+
]);
|
|
335
|
+
})
|
|
336
|
+
.then((result) => {
|
|
337
|
+
opening.delete(form.slot);
|
|
338
|
+
return { ...result, asked: entry.asked || entry.opened };
|
|
339
|
+
});
|
|
340
|
+
opening.set(form.slot, entry);
|
|
341
|
+
return entry.done;
|
|
263
342
|
}
|
|
264
343
|
|
|
265
344
|
/**
|
|
266
345
|
* `kept`: a teaser's tab carried from an earlier page, shown again whatever its caps. `asked`: the
|
|
267
346
|
* shopper asked for it (a `manual` form, or one opened by an alias), so its counters do not apply.
|
|
347
|
+
* `deadline`: when its decision, renderer and products must have come (DEADLINE_MS).
|
|
268
348
|
*/
|
|
269
|
-
async function attempt(form, reason, kept, asked) {
|
|
349
|
+
async function attempt(form, reason, kept, asked, deadline) {
|
|
270
350
|
if (suppressed()) {
|
|
271
|
-
return note(form, 'not shown', [
|
|
351
|
+
return note(form, 'not shown', 'suppressed', [
|
|
272
352
|
`suppressed: ${[...window[SUPPRESSED]].join(', ')}`,
|
|
273
353
|
]);
|
|
274
354
|
}
|
|
@@ -278,24 +358,29 @@ export function startOnsite(client, options = {}) {
|
|
|
278
358
|
asked,
|
|
279
359
|
});
|
|
280
360
|
if (reasons.length) {
|
|
281
|
-
return note(form, 'not eligible', reasons);
|
|
361
|
+
return note(form, 'not eligible', 'not-eligible', reasons);
|
|
282
362
|
}
|
|
283
363
|
// The gap first: it holds for the whole session, so it is the reason worth reading.
|
|
284
364
|
const interrupts = INTERRUPTS(form.format) && !kept;
|
|
285
365
|
const wait = interrupts && !asked && gapHolds();
|
|
286
366
|
if (wait) {
|
|
287
|
-
return note(form, 'not shown', [wait]);
|
|
367
|
+
return note(form, 'not shown', 'overlay-gap', [wait]);
|
|
288
368
|
}
|
|
289
369
|
// Never over an unanswered consent banner: the shopper answers it first, whatever the answer.
|
|
290
370
|
if (interrupts && !access.preview && access.consentAnswered?.() === false) {
|
|
291
371
|
awaitingConsent.set(form, asked);
|
|
292
|
-
return note(form, 'not shown',
|
|
372
|
+
return note(form, 'not shown', 'awaiting-consent', [
|
|
373
|
+
'waiting for the consent banner',
|
|
374
|
+
]);
|
|
293
375
|
}
|
|
294
376
|
const covers = OVERLAY(form.format);
|
|
295
377
|
const shown = covers && blocker(asked);
|
|
296
|
-
if (shown)
|
|
378
|
+
if (shown)
|
|
379
|
+
return note(form, 'not shown', 'another-showing', [
|
|
380
|
+
`${shown} is showing`,
|
|
381
|
+
]);
|
|
297
382
|
if (covers && spent && !asked) {
|
|
298
|
-
return note(form, 'not shown', [
|
|
383
|
+
return note(form, 'not shown', 'allowance-spent', [
|
|
299
384
|
`${spent} already showed on this page view`,
|
|
300
385
|
]);
|
|
301
386
|
}
|
|
@@ -307,12 +392,14 @@ export function startOnsite(client, options = {}) {
|
|
|
307
392
|
container = null;
|
|
308
393
|
}
|
|
309
394
|
if (!container) {
|
|
310
|
-
return note(form, 'not shown',
|
|
395
|
+
return note(form, 'not shown', 'no-element', [
|
|
396
|
+
'no element matches its selector',
|
|
397
|
+
]);
|
|
311
398
|
}
|
|
312
399
|
}
|
|
313
400
|
if (covers) covering.set(form.slot, form);
|
|
314
401
|
unwatch(form);
|
|
315
|
-
note(form, 'asking for a decision', [reason]);
|
|
402
|
+
note(form, 'asking for a decision', 'deciding', [reason]);
|
|
316
403
|
// The renderer is fetched while the decision is asked, so neither waits on the other.
|
|
317
404
|
const renderer = loadRender().catch((err) => {
|
|
318
405
|
console.warn('[hp] onsite renderer not loaded:', err?.message ?? err);
|
|
@@ -332,7 +419,7 @@ export function startOnsite(client, options = {}) {
|
|
|
332
419
|
.slice(0, 100);
|
|
333
420
|
const claimed =
|
|
334
421
|
form.audiences.length > 0 && !access.preview && access.personalisation()
|
|
335
|
-
? await
|
|
422
|
+
? await within(
|
|
336
423
|
audiencesChunk().then((chunk) =>
|
|
337
424
|
chunk
|
|
338
425
|
? chunk.visitAudiences(
|
|
@@ -343,20 +430,33 @@ export function startOnsite(client, options = {}) {
|
|
|
343
430
|
)
|
|
344
431
|
: given,
|
|
345
432
|
),
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const res = await api.decide(form.slot, counters.pin(form.slot), {
|
|
350
|
-
visitAudiences: claimed,
|
|
351
|
-
visitCampaign: form.arrived_from.length ? campaign() : null,
|
|
352
|
-
// Only for a form whose products are filled from it; never kept or logged by the server.
|
|
353
|
-
visitContext:
|
|
354
|
-
form.visit_context === true ? (access.visitContext?.() ?? null) : null,
|
|
355
|
-
});
|
|
356
|
-
const decision =
|
|
357
|
-
res.status === 200
|
|
358
|
-
? res.body?.decisions?.find((d) => d?.slot === form.slot)
|
|
433
|
+
Math.min(deadline, Date.now() + CLAIMS_MS),
|
|
434
|
+
given,
|
|
435
|
+
)
|
|
359
436
|
: null;
|
|
437
|
+
// Nothing is asked once the deadline has passed: a decision never shown is never made.
|
|
438
|
+
const res =
|
|
439
|
+
Date.now() < deadline
|
|
440
|
+
? await within(
|
|
441
|
+
api.decide(form.slot, counters.pin(form.slot), {
|
|
442
|
+
visitAudiences: claimed,
|
|
443
|
+
visitCampaign: form.arrived_from.length ? campaign() : null,
|
|
444
|
+
// Only for a form whose products are filled from it; never kept or logged by the server.
|
|
445
|
+
visitContext:
|
|
446
|
+
form.visit_context === true
|
|
447
|
+
? (access.visitContext?.() ?? null)
|
|
448
|
+
: null,
|
|
449
|
+
timeoutMs: deadline - Date.now(),
|
|
450
|
+
}),
|
|
451
|
+
deadline,
|
|
452
|
+
LATE,
|
|
453
|
+
)
|
|
454
|
+
: LATE;
|
|
455
|
+
// An answer without a list of decisions is a failed one; a list without the slot, no creative.
|
|
456
|
+
const decisions = res.status === 200 ? res.body?.decisions : null;
|
|
457
|
+
const decision = Array.isArray(decisions)
|
|
458
|
+
? decisions.find((d) => d?.slot === form.slot)
|
|
459
|
+
: null;
|
|
360
460
|
// The version this visit was shown in the form's A/B test, kept for the rest of it (ADR 0065).
|
|
361
461
|
if (decision?.experiment && decision.arm && decision.split)
|
|
362
462
|
counters.pin(form.slot, [
|
|
@@ -381,62 +481,59 @@ export function startOnsite(client, options = {}) {
|
|
|
381
481
|
!(basket > 0) &&
|
|
382
482
|
'the basket is empty');
|
|
383
483
|
if (missing) {
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
return
|
|
484
|
+
const empty = missing === 'the basket is empty';
|
|
485
|
+
if (empty) awaitingBasket.add(form);
|
|
486
|
+
return drop(form, 'not shown', empty ? 'empty-basket' : 'no-code', [
|
|
487
|
+
missing,
|
|
488
|
+
]);
|
|
387
489
|
}
|
|
388
490
|
awaitingBasket.delete(form);
|
|
389
491
|
if (!creative) {
|
|
492
|
+
const held = decision?.holdout === true;
|
|
390
493
|
// A held-out visitor whose trigger fired is exposed to the default, as a treated one is,
|
|
391
494
|
// and its page stays one with no popup: the allowance is spent as a shown one spends it.
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
covering.delete(form.slot);
|
|
395
|
-
if (covers && decision?.holdout === true) spent ??= form.slot;
|
|
495
|
+
if (held && access.analytics()) client.recordExposure(res.body);
|
|
496
|
+
if (covers && held) spent ??= form.slot;
|
|
396
497
|
// The site shows a held-out visitor what it shows without HP, at the same moment; a form
|
|
397
498
|
// the site opened hears so from `open`, which resolves false.
|
|
398
|
-
if (
|
|
499
|
+
if (held && !asked)
|
|
399
500
|
emit('held', {
|
|
400
501
|
slot: form.slot,
|
|
401
502
|
arm: decision.arm ?? null,
|
|
402
503
|
format: form.format,
|
|
403
504
|
});
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const render = await renderer;
|
|
415
|
-
if (!render) {
|
|
416
|
-
covering.delete(form.slot);
|
|
417
|
-
return note(form, 'not shown', ['the renderer did not load']);
|
|
505
|
+
if (held || Array.isArray(decisions))
|
|
506
|
+
return drop(
|
|
507
|
+
form,
|
|
508
|
+
held ? 'held out' : 'no decision',
|
|
509
|
+
held ? 'held-out' : 'no-creative',
|
|
510
|
+
['no creative for this visitor'],
|
|
511
|
+
);
|
|
512
|
+
return drop(form, 'no decision', 'decision-failed', [
|
|
513
|
+
`decisions answered ${res.status}`,
|
|
514
|
+
]);
|
|
418
515
|
}
|
|
516
|
+
const render = await within(renderer, deadline, null);
|
|
517
|
+
if (!render)
|
|
518
|
+
return drop(form, 'not shown', 'renderer-failed', [
|
|
519
|
+
'the renderer did not load',
|
|
520
|
+
]);
|
|
419
521
|
// Its products are drawn by their own chunk: without it the form would open on an empty block.
|
|
420
522
|
if (showsProducts(creative)) {
|
|
421
523
|
cards ??= loadProducts().catch(() => null);
|
|
422
|
-
if (!(await cards))
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
524
|
+
if (!(await within(cards, deadline, null)))
|
|
525
|
+
return drop(form, 'not shown', 'products-failed', [
|
|
526
|
+
'its products did not load',
|
|
527
|
+
]);
|
|
426
528
|
}
|
|
427
529
|
// Its first step's pictures fetched and decoded first, so it never opens on an empty one, and
|
|
428
|
-
// never waits for them longer than WARM_MS.
|
|
429
|
-
await
|
|
530
|
+
// never waits for them longer than WARM_MS or past its deadline.
|
|
531
|
+
await within(
|
|
430
532
|
render.warm?.(form, creative, creative.steps[0].id, warmed),
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (suppressed())
|
|
434
|
-
|
|
435
|
-
return note(form, 'not shown', ['suppressed']);
|
|
436
|
-
}
|
|
437
|
-
if (!kept) counters.shown(form.slot);
|
|
438
|
-
if (interrupts) counters.covered();
|
|
439
|
-
if (covers) spent ??= form.slot;
|
|
533
|
+
Math.min(deadline, Date.now() + WARM_MS),
|
|
534
|
+
);
|
|
535
|
+
if (suppressed())
|
|
536
|
+
return drop(form, 'not shown', 'suppressed', ['suppressed']);
|
|
440
537
|
const tell = (name, detail = {}) =>
|
|
441
538
|
emit(name, {
|
|
442
539
|
slot: form.slot,
|
|
@@ -463,37 +560,71 @@ export function startOnsite(client, options = {}) {
|
|
|
463
560
|
onClose(why) {
|
|
464
561
|
mounted.delete(form.slot);
|
|
465
562
|
covering.delete(form.slot);
|
|
466
|
-
note(form, 'closed', [why]);
|
|
563
|
+
note(form, 'closed', 'closed', [why]);
|
|
467
564
|
tell('closed', { reason: why });
|
|
468
565
|
},
|
|
469
566
|
});
|
|
470
567
|
mounted.set(form.slot, handle);
|
|
568
|
+
// Counted once it is on screen, so a form that failed to open spends none of its caps.
|
|
569
|
+
if (!kept) counters.shown(form.slot);
|
|
570
|
+
if (interrupts) counters.covered();
|
|
571
|
+
if (covers) spent ??= form.slot;
|
|
471
572
|
if (access.analytics()) client.recordExposure(res.body);
|
|
472
|
-
note(form, 'shown', [reason]);
|
|
573
|
+
const result = note(form, 'shown', 'shown', [reason]);
|
|
473
574
|
tell('shown');
|
|
474
|
-
return
|
|
575
|
+
return result;
|
|
475
576
|
}
|
|
476
577
|
|
|
477
578
|
/**
|
|
478
579
|
* Triggers that fire within FIRE_WINDOW_MS of each other compete: only the highest priority shows.
|
|
479
580
|
* One that cannot show passes to the next; a held-out visitor sees none, so the holdout stays clean.
|
|
581
|
+
* They share one deadline. When none shows or is held out, the highest that HP had nothing for
|
|
582
|
+
* (NOTHING), unless the site asked for it, says `nothing`: a site whose own popup HP's form
|
|
583
|
+
* replaces shows its own there, and that spends the page view's allowance.
|
|
480
584
|
*/
|
|
481
585
|
function fire(form, reason, asked = ASKED(form)) {
|
|
482
586
|
if (!OVERLAY(form.format)) return show(form, reason, false, asked);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
587
|
+
if (!batch) {
|
|
588
|
+
// Set when the first trigger enters, so a timer the browser runs late takes none of it.
|
|
589
|
+
const fired = {
|
|
590
|
+
forms: [],
|
|
591
|
+
taken: new Set(),
|
|
592
|
+
deadline: Date.now() + FIRE_WINDOW_MS + DEADLINE_MS,
|
|
593
|
+
};
|
|
594
|
+
batch = fired;
|
|
595
|
+
windows.add(fired);
|
|
596
|
+
setTimeout(() => {
|
|
597
|
+
batch = null;
|
|
598
|
+
decideFired(fired);
|
|
599
|
+
}, FIRE_WINDOW_MS);
|
|
600
|
+
}
|
|
601
|
+
batch.forms.push([form, reason, asked]);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The forms of one window, highest first, until one shows or is held out. A form the site opened
|
|
606
|
+
* meanwhile (`taken`) is the site's: it gets no attempt here, and no `nothing`.
|
|
607
|
+
*/
|
|
608
|
+
async function decideFired(fired) {
|
|
609
|
+
const queue = fired.forms.sort(([a], [b]) => byPriority(a, b));
|
|
610
|
+
// The site takes the place of the highest that HP had nothing for, and only that one.
|
|
611
|
+
let missed = null;
|
|
612
|
+
for (const [i, [next, why, ask]] of queue.entries()) {
|
|
613
|
+
if (fired.taken.has(next)) continue;
|
|
614
|
+
const result = await show(next, why, false, ask, fired.deadline);
|
|
615
|
+
if (!missed && NOTHING.has(result.code) && !result.asked)
|
|
616
|
+
missed = [next, result.code];
|
|
617
|
+
if (result.state === 'shown' || result.state === 'held out') {
|
|
618
|
+
for (const [f] of queue.slice(i + 1))
|
|
619
|
+
note(f, 'not shown', 'lower-priority', [
|
|
620
|
+
`${next.slot} has a higher priority`,
|
|
621
|
+
]);
|
|
622
|
+
missed = null;
|
|
623
|
+
break;
|
|
495
624
|
}
|
|
496
|
-
}
|
|
625
|
+
}
|
|
626
|
+
windows.delete(fired);
|
|
627
|
+
if (missed) say(...missed);
|
|
497
628
|
}
|
|
498
629
|
|
|
499
630
|
for (const form of forms) {
|
|
@@ -504,11 +635,12 @@ export function startOnsite(client, options = {}) {
|
|
|
504
635
|
}
|
|
505
636
|
const reasons = blockers(form, env(form));
|
|
506
637
|
if (reasons.length) {
|
|
507
|
-
note(form, 'not eligible', reasons);
|
|
638
|
+
note(form, 'not eligible', 'not-eligible', reasons);
|
|
508
639
|
continue;
|
|
509
640
|
}
|
|
510
641
|
const type = form.trigger.type;
|
|
511
|
-
if (type === 'manual')
|
|
642
|
+
if (type === 'manual')
|
|
643
|
+
note(form, 'waiting for hp.onsite.open', 'waiting-for-open');
|
|
512
644
|
else if (type === 'inline' || form.format === 'inline')
|
|
513
645
|
show(form, 'inline');
|
|
514
646
|
else arm(form);
|
|
@@ -529,7 +661,7 @@ export function startOnsite(client, options = {}) {
|
|
|
529
661
|
{ device },
|
|
530
662
|
),
|
|
531
663
|
);
|
|
532
|
-
note(form, 'armed', [`${form.trigger.type} on ${device}`]);
|
|
664
|
+
note(form, 'armed', 'armed', [`${form.trigger.type} on ${device}`]);
|
|
533
665
|
}
|
|
534
666
|
|
|
535
667
|
// Once the banner is answered, a form that waited is armed again, so it opens at its trigger's
|
|
@@ -563,11 +695,14 @@ export function startOnsite(client, options = {}) {
|
|
|
563
695
|
if (!form) return Promise.resolve(false);
|
|
564
696
|
// One already on screen (an inline form) is shown, not mounted twice. A form opened by an
|
|
565
697
|
// alias is the shopper asking, as a `manual` one is.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
698
|
+
if (mounted.has(form.slot)) return Promise.resolve(true);
|
|
699
|
+
// The site's open takes the place of a trigger of the same form still in a window.
|
|
700
|
+
for (const fired of windows) fired.taken.add(form);
|
|
701
|
+
const opened = show(form, 'manual', false, ASKED(form) || !keyed);
|
|
702
|
+
// The site hears from this `open`, so the attempt it may have joined says no `nothing`.
|
|
703
|
+
const entry = opening.get(form.slot);
|
|
704
|
+
if (entry) entry.opened = true;
|
|
705
|
+
return opened.then(({ state }) => state === 'shown');
|
|
571
706
|
},
|
|
572
707
|
/** `hp.onsite.suppress`: take every form off the screen, quietly; none shows until unsuppressed. */
|
|
573
708
|
suppress() {
|
|
@@ -576,14 +711,15 @@ export function startOnsite(client, options = {}) {
|
|
|
576
711
|
mounted.delete(slot);
|
|
577
712
|
covering.delete(slot);
|
|
578
713
|
const form = forms.find((f) => f.slot === slot);
|
|
579
|
-
if (form) note(form, 'closed', ['suppressed']);
|
|
714
|
+
if (form) note(form, 'closed', 'suppressed', ['suppressed']);
|
|
580
715
|
}
|
|
581
716
|
},
|
|
582
717
|
/** Each form's state on this page and why, in settings order. */
|
|
583
718
|
debug: () =>
|
|
584
719
|
[...trace.values()].map((t) => ({ ...t, reasons: [...t.reasons] })),
|
|
585
|
-
/** Stop watching every trigger. A form on screen stays until it is closed. */
|
|
720
|
+
/** Stop watching every trigger, and say no `nothing` after. A form on screen stays until it is closed. */
|
|
586
721
|
stop() {
|
|
722
|
+
stopped = true;
|
|
587
723
|
for (const stop of stops.values()) stop();
|
|
588
724
|
stops.clear();
|
|
589
725
|
stopBasket?.();
|
package/src/onsite/preview.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `device` picks the small-screen layout whatever the console's own width.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { answerFields, answerOf } from './answers.js';
|
|
9
|
+
import { answerCards, answerFields, answerOf, countdown } from './answers.js';
|
|
10
10
|
import { mountProducts } from './products.js';
|
|
11
11
|
import { mountForm } from './render.js';
|
|
12
12
|
import { readCreative } from './rules.js';
|
|
@@ -67,7 +67,8 @@ export function mountPreview(host, creative, options = {}) {
|
|
|
67
67
|
api: null,
|
|
68
68
|
counters: inert,
|
|
69
69
|
loadWheel: () => Promise.resolve({ mountWheel }),
|
|
70
|
-
loadAnswers: () =>
|
|
70
|
+
loadAnswers: () =>
|
|
71
|
+
Promise.resolve({ answerCards, answerFields, answerOf, countdown }),
|
|
71
72
|
loadProducts: () => Promise.resolve({ mountProducts }),
|
|
72
73
|
basket: () =>
|
|
73
74
|
Number.isFinite(opts.basketValue) ? opts.basketValue : SAMPLE_BASKET,
|