@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.
@@ -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
- let fired = [];
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. A form on screen is
250
- * not mounted again, and a second call while it is being opened (a double click during the
251
- * decision) shares the first one's answer.
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(form, reason, kept = false, asked = ASKED(form)) {
254
- if (mounted.has(form.slot)) return Promise.resolve('shown');
255
- if (!opening.has(form.slot))
256
- opening.set(
257
- form.slot,
258
- attempt(form, reason, kept, asked).finally(() =>
259
- opening.delete(form.slot),
260
- ),
261
- );
262
- return opening.get(form.slot);
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', ['waiting for the consent banner']);
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) return note(form, 'not shown', [`${shown} is showing`]);
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', ['no element matches its selector']);
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 Promise.race([
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
- new Promise((resolve) => setTimeout(resolve, CLAIMS_MS, given)),
347
- ])
348
- : null;
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
- covering.delete(form.slot);
385
- if (missing === 'the basket is empty') awaitingBasket.add(form);
386
- return note(form, 'not shown', [missing]);
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 (decision?.holdout === true && access.analytics())
393
- client.recordExposure(res.body);
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 (decision?.holdout === true && !asked)
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
- return note(
405
- form,
406
- decision?.holdout === true ? 'held out' : 'no decision',
407
- [
408
- res.status === 200
409
- ? 'no creative for this visitor'
410
- : `decisions answered ${res.status}`,
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
- covering.delete(form.slot);
424
- return note(form, 'not shown', ['its products did not load']);
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 Promise.race([
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
- new Promise((resolve) => setTimeout(resolve, WARM_MS)),
432
- ]);
433
- if (suppressed()) {
434
- covering.delete(form.slot);
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 'shown';
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
- fired.push([form, reason, asked]);
484
- if (fired.length > 1) return;
485
- setTimeout(async () => {
486
- const queue = fired.sort(([a], [b]) => byPriority(a, b));
487
- fired = [];
488
- for (const [i, [next, why, ask]] of queue.entries()) {
489
- const state = await show(next, why, false, ask);
490
- if (state === 'shown' || state === 'held out') {
491
- for (const [f] of queue.slice(i + 1))
492
- note(f, 'not shown', [`${next.slot} has a higher priority`]);
493
- return;
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
- }, FIRE_WINDOW_MS);
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') note(form, 'waiting for hp.onsite.open');
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
- return mounted.has(form.slot)
567
- ? Promise.resolve(true)
568
- : show(form, 'manual', false, ASKED(form) || !keyed).then(
569
- (state) => state === 'shown',
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?.();
@@ -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: () => Promise.resolve({ answerFields, answerOf }),
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,