@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.
@@ -11,7 +11,10 @@
11
11
 
12
12
  import { watchViewability } from '../auto/viewability.js';
13
13
  import { httpsHref } from './rich-text.js';
14
+ import { isObject } from './rules.js';
14
15
 
16
+ // `.btn.bs` is a small button, for a card's: here, as no other block has one, and not `.sm`, which
17
+ // is the stylesheet's small print.
15
18
  const CSS = `.pr ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}
16
19
  .pr-c ul{grid-auto-flow:column;grid-auto-columns:minmax(132px,42%);overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;padding-bottom:6px;overscroll-behavior-x:contain}
17
20
  .pr-c li{scroll-snap-align:start}
@@ -26,12 +29,16 @@ const CSS = `.pr ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}
26
29
  .pn{font-size:14px;line-height:1.3;min-height:2.6em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
27
30
  .pp{font-size:14px;font-weight:700}
28
31
  .pp s{font-weight:400;color:var(--mu)}
29
- .pb{margin-top:auto;padding:8px 12px;font-size:14px}
32
+ .btn.bs{min-height:40px;padding:8px 12px;font-size:14px}
33
+ .pb{margin-top:auto;white-space:nowrap}
34
+ .pr-c{position:relative}
35
+ .ar{position:absolute;z-index:1;top:calc(var(--ph,132px)/2 - 20px);left:-6px;width:40px;height:40px;padding:0 0 3px;border:1px solid var(--ln);border-radius:50%;background:var(--b);color:var(--t);box-shadow:0 2px 8px rgb(0 0 0/.15);font:24px/1 sans-serif;cursor:pointer}
36
+ .ar+.ar{left:auto;right:-6px}
37
+ .ar[aria-disabled=true]{opacity:.35;cursor:default}
38
+ .mob .ar,.pr-f .ar{display:none}
30
39
  @media (prefers-reduced-motion:reduce){.pr-c ul{scroll-behavior:auto}}`;
31
40
 
32
41
  const ALIGNS = ['left', 'center', 'right'];
33
- const isObject = (v) =>
34
- v !== null && typeof v === 'object' && !Array.isArray(v);
35
42
 
36
43
  /** Money in the form's language; the bare amount for a currency the browser does not know. */
37
44
  function moneyIn(lang, currency) {
@@ -54,7 +61,8 @@ function moneyIn(lang, currency) {
54
61
  * @param {{ el: Function, lang?: string, root: ShadowRoot, placement: string, preview: boolean,
55
62
  * client: object|null, engaged: Function, seen: Set<string> }} o `client` is null when nothing
56
63
  * is reported; `seen` holds the form's rails already reported seen, so a step shown again is not.
57
- * @returns {(() => void)|undefined} Stops watching the cards, for when the step changes.
64
+ * @returns {(() => void)|undefined} Stops watching the cards and the row's size, for when the step
65
+ * changes.
58
66
  */
59
67
  export function mountProducts(box, block, o) {
60
68
  const { el } = o;
@@ -149,7 +157,7 @@ export function mountProducts(box, block, o) {
149
157
  if (show.name === false && !image)
150
158
  a.setAttribute('aria-label', name || p.product_id);
151
159
  if (typeof block.button === 'string' && block.button)
152
- a.append(el('span', 'btn pb', block.button));
160
+ a.append(el('span', 'btn bs pb', block.button));
153
161
  a.href = httpsHref(p.url);
154
162
  a.rel = 'noopener';
155
163
  a.addEventListener('click', (e) => {
@@ -170,21 +178,70 @@ export function mountProducts(box, block, o) {
170
178
  list.append(item);
171
179
  }
172
180
  box.append(style, list);
173
- if (!client || seen.has(id)) return;
174
- return watchViewability({
175
- selector: '[data-hp-product]',
176
- root: o.root,
177
- onViewable(node) {
178
- const position = Number(node.dataset.hpPosition);
179
- if (!viewable.has(position))
180
- viewable.set(position, {
181
- position,
182
- product_id: node.dataset.hpProduct,
183
- viewable_at: new Date().toISOString(),
184
- });
185
- // Deferred a turn, so cards that became viewable together land on one event.
186
- pending ??= setTimeout(impression, 0);
187
- return true;
188
- },
181
+ const ro = grid ? null : arrows(box, list, el);
182
+ const stop =
183
+ client &&
184
+ !seen.has(id) &&
185
+ watchViewability({
186
+ selector: '[data-hp-product]',
187
+ root: o.root,
188
+ onViewable(node) {
189
+ const position = Number(node.dataset.hpPosition);
190
+ if (!viewable.has(position))
191
+ viewable.set(position, {
192
+ position,
193
+ product_id: node.dataset.hpProduct,
194
+ viewable_at: new Date().toISOString(),
195
+ });
196
+ // Deferred a turn, so cards that became viewable together land on one event.
197
+ pending ??= setTimeout(impression, 0);
198
+ return true;
199
+ },
200
+ });
201
+ return () => {
202
+ ro?.disconnect();
203
+ if (stop) stop();
204
+ };
205
+ }
206
+
207
+ /**
208
+ * A carousel's previous and next buttons, for a mouse on a large screen (a phone swipes; Tab walks
209
+ * the cards): each scrolls a page of cards, as smoothly as the shopper allows, and says it can go
210
+ * no further at its end. Hidden when every card fits. They sit level with the middle of the
211
+ * pictures, whose height (`--ph`, a square as wide as a card) is measured with the row. They come
212
+ * before the row, so Tab reaches them before the cards they page. Returns what watches the row's
213
+ * size.
214
+ */
215
+ function arrows(box, list, el) {
216
+ const buttons = ['Previous', 'Next'].map((label, i) => {
217
+ const b = el('button', 'ar', i ? '›' : '‹');
218
+ b.type = 'button';
219
+ b.setAttribute('aria-label', `${label} products`);
220
+ b.addEventListener('click', () =>
221
+ list.scrollBy({ left: (i ? 1 : -1) * list.clientWidth }),
222
+ );
223
+ list.before(b);
224
+ return b;
189
225
  });
226
+ // Every read before any write, and a write only when something changed, so scrolling never
227
+ // makes the browser lay the row out again.
228
+ let painted = '';
229
+ const paint = () => {
230
+ const end = list.scrollWidth - list.clientWidth;
231
+ const at = list.scrollLeft;
232
+ const picture = list.firstElementChild?.offsetWidth ?? 0;
233
+ const now = [end < 2, at < 2, at > end - 2, picture];
234
+ if (`${now}` === painted) return;
235
+ painted = `${now}`;
236
+ box.classList.toggle('pr-f', now[0]);
237
+ box.style.setProperty('--ph', `${picture}px`);
238
+ buttons[0].setAttribute('aria-disabled', String(now[1]));
239
+ buttons[1].setAttribute('aria-disabled', String(now[2]));
240
+ };
241
+ list.addEventListener('scroll', paint, { passive: true });
242
+ paint();
243
+ // Told the row's size again once it is laid out, and as it changes.
244
+ const ro = globalThis.ResizeObserver && new ResizeObserver(paint);
245
+ ro?.observe(list);
246
+ return ro;
190
247
  }
@@ -11,7 +11,15 @@
11
11
 
12
12
  import { solve } from './pow.js';
13
13
  import { assetHref, httpsHref, richText } from './rich-text.js';
14
- import { CALLING_CODES, e164, isEmail, nextStep, select } from './rules.js';
14
+ import {
15
+ CALLING_CODES,
16
+ cardColumns,
17
+ e164,
18
+ isEmail,
19
+ isObject,
20
+ nextStep,
21
+ select,
22
+ } from './rules.js';
15
23
  import { CSS, FONTS } from './styles.js';
16
24
 
17
25
  const HEX = /^#(?:[0-9a-f]{3}){1,2}$/i;
@@ -62,8 +70,6 @@ const COPY = {
62
70
  won: 'You won',
63
71
  };
64
72
 
65
- const isObject = (v) =>
66
- v !== null && typeof v === 'object' && !Array.isArray(v);
67
73
  const oneOf = (list, v, fallback) => (list.includes(v) ? v : fallback);
68
74
  const num = (v, lo, hi) =>
69
75
  Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : null;
@@ -104,20 +110,27 @@ const sizeOf = (t, l) =>
104
110
  (l.template === 'split' || l.template === 'overlay' ? 'lg' : 'md');
105
111
 
106
112
  /**
107
- * How wide an image shows, for the browser to pick from its srcset: the whole screen on a small
108
- * one or a full-size form, else `part` of the card, a sheet or a teaser's tab.
113
+ * How wide an image shows, for the browser to pick from its srcset: `small` (the whole screen) on
114
+ * a small one or a full-size form, else `part` of the card, a sheet or a teaser's tab.
109
115
  */
110
- const across = (mode, size, part = 1) => {
111
- if (mode === 'fullscreen' || size === 'full') return '100vw';
116
+ const across = (mode, size, part = 1, small = '100vw') => {
117
+ if (mode === 'fullscreen' || size === 'full') return small;
112
118
  const px =
113
119
  mode === 'teaser'
114
120
  ? 220
115
121
  : mode === 'sheet'
116
122
  ? 400
117
123
  : (CARD_PX[size] ?? CARD_PX.md);
118
- return `(max-width: 639px) 100vw, ${Math.round(px * part)}px`;
124
+ return `(max-width: 639px) ${small}, ${Math.round(px * part)}px`;
119
125
  };
120
126
 
127
+ /**
128
+ * A picture card of question `q`'s share of the form: half a narrow form, two a row, and of a large
129
+ * one one of its columns (`cardColumns`, as answers.js lays them out). On a phone, half the screen
130
+ * (`across`'s `small`, '50vw').
131
+ */
132
+ const cardPart = (size, q) => (size === 'lg' ? 1 / cardColumns(q) : 0.5);
133
+
121
134
  /**
122
135
  * The smaller copies the upload made of a WebP image (`widths`, any of them, each at `-<w>w.webp`)
123
136
  * and the image itself at its width, as a srcset; null when there are none to offer.
@@ -187,21 +200,32 @@ export function warm(form, creative, id, seen = new Set()) {
187
200
  const size = sizeOf(creative.theme, l);
188
201
  // A teaser's tab shows only its button, or its picture when that is all it is: the rest of the
189
202
  // form opens as a popup, warmed with the popup's sizes once the tab is up (warmNext).
203
+ // Each entry: a picture, and its part of the card and of a phone, as the renderer sizes it;
204
+ // picture cards, one picture an answer, as many as the question has.
190
205
  const wanted = tab
191
206
  ? []
192
- : step.blocks
193
- .filter((b) => isObject(b) && (b.t === 'image' || b.t === 'video'))
194
- .map((b) => [b, 1]);
207
+ : step.blocks.flatMap((b) => {
208
+ if (b?.t === 'image' || b?.t === 'video') return [[b]];
209
+ // Picture cards are a single choice's: any other question shows as it would without them.
210
+ const q =
211
+ b?.display === 'cards' &&
212
+ form.questions.find((x) => x.key === b.question && x.type === 'one');
213
+ return q
214
+ ? Object.values(b.pictures ?? {})
215
+ .filter(isObject)
216
+ .map((p) => [p, cardPart(size, q), '50vw'])
217
+ : [];
218
+ });
195
219
  if (l.media && !(tab && l.template !== 'image-only'))
196
220
  wanted.push([l.media, l.template === 'split' ? 0.46 : 1]);
197
221
  return Promise.all(
198
- wanted.map(([m, part]) => {
222
+ wanted.map(([m, part, small]) => {
199
223
  const src = assetHref(isVideo(m) ? m.poster : m.asset);
200
224
  if (!src) return null;
201
225
  const img = document.createElement('img');
202
226
  const set = !isVideo(m) && srcset(m, src, num(m.width, 1, 8192));
203
227
  if (set) {
204
- img.sizes = across(mode, size, part);
228
+ img.sizes = across(mode, size, part, small);
205
229
  img.srcset = set;
206
230
  }
207
231
  const key = `${img.sizes} ${set || src}`;
@@ -320,8 +344,9 @@ function styles(root) {
320
344
  * @param {() => Promise<{ mountWheel: Function }>} o.loadWheel
321
345
  * @param {() => Promise<{ mountProducts: Function }>} [o.loadProducts] A products block's cards
322
346
  * (./products.js), loaded when a step shows one.
323
- * @param {() => Promise<{ answerFields: Function, answerOf: Function }>} o.loadAnswers The fields of
324
- * a date or number question (./answers.js), loaded once as the form mounts when it asks one.
347
+ * @param {() => Promise<{ answerFields: Function, answerOf: Function, answerCards: Function, countdown: Function }>} o.loadAnswers
348
+ * A date or number question's fields, picture cards and a countdown's ticking (./answers.js),
349
+ * loaded once as the form mounts when it shows any of them.
325
350
  * @param {Element|null} [o.container] The page's element, for an inline form.
326
351
  * @param {(reason: string) => void} [o.onClose]
327
352
  * @param {(name: 'submitted'|'apply', detail: object) => void} [o.emit] To `hp.onsite.on` listeners.
@@ -397,7 +422,11 @@ export function mountForm(o) {
397
422
  modal: false,
398
423
  mobile: preview?.device === 'mobile',
399
424
  answers: {},
425
+ // The step each answer was given on, by question.
426
+ answeredOn: {},
400
427
  sent: new Set(),
428
+ // Whether this view's click is counted: a view counts one, whatever was clicked.
429
+ clicked: false,
401
430
  // The products blocks' rails reported seen, by id: a step shown again does not report it twice.
402
431
  seen: new Set(),
403
432
  receipt: null,
@@ -427,10 +456,15 @@ export function mountForm(o) {
427
456
 
428
457
  // The form's language, else the page's: a phone field's country, a birthday's months, money.
429
458
  const lang = form.locale || document.documentElement.lang;
430
- // ./answers.js, asked for once, as the form mounts, when it asks a date or a number; null when it
431
- // could not load. Each of their fieldsets reads its answer with it.
459
+ // ./answers.js, fetched once as the form mounts when it asks a date or a number, or any step
460
+ // shows picture cards or a countdown, so none waits for it once its step shows; null when it
461
+ // could not load. Each date or number reads its answer with it.
432
462
  const fields =
433
- form.questions.some(typed) && o.loadAnswers().catch(() => null);
463
+ (form.questions.some(typed) ||
464
+ creative.steps.some((s) =>
465
+ s.blocks.some((b) => b?.t === 'countdown' || b?.display === 'cards'),
466
+ )) &&
467
+ o.loadAnswers().catch(() => null);
434
468
  const readers = new WeakMap();
435
469
 
436
470
  const classes = () => {
@@ -451,8 +485,17 @@ export function mountForm(o) {
451
485
  small.addEventListener?.('change', resize);
452
486
  }
453
487
 
488
+ /** A form that collects nothing: a message, products, or a quiz. */
489
+ const asksNothing = !form.channels?.length;
490
+
454
491
  const engaged = (engagement, step, item = {}) => {
455
492
  if (preview || !decision.decision_id || !access.analytics()) return;
493
+ // A view counts one click; a products card's always says which product was clicked.
494
+ if (engagement === 'click') {
495
+ const counted = state.clicked;
496
+ state.clicked = true;
497
+ if (counted && !item.products) return;
498
+ }
456
499
  client.slotEngaged({
457
500
  decision_id: decision.decision_id,
458
501
  slot: decision.slot,
@@ -511,9 +554,10 @@ export function mountForm(o) {
511
554
  /**
512
555
  * An image or video for an asset, its box reserved by its size and cropped to its focal point.
513
556
  * `first`: on the form's first step, fetched at once and ahead of the page's other images; a
514
- * later step's waits until it shows (and was warmed while the step before it showed).
557
+ * later step's waits until it shows (and was warmed while the step before it showed). It
558
+ * shows across `part` of the card, and `small` of a phone (`across`).
515
559
  */
516
- function visual(m, className, first, part) {
560
+ function visual(m, className, first, part, small) {
517
561
  const src = assetHref(m.asset);
518
562
  if (!src) return null;
519
563
  const alt = typeof m.alt === 'string' ? m.alt : '';
@@ -551,7 +595,7 @@ export function mountForm(o) {
551
595
  const set = srcset(m, src, num(m.width, 1, 8192));
552
596
  // sizes before srcset before src, so the browser fetches one file, the right one.
553
597
  if (set) {
554
- node.sizes = across(state.mode, state.size, part);
598
+ node.sizes = across(state.mode, state.size, part, small);
555
599
  node.srcset = set;
556
600
  }
557
601
  node.src = src;
@@ -610,8 +654,8 @@ export function mountForm(o) {
610
654
 
611
655
  /**
612
656
  * A question as its type says: a choice as buttons, a list of radio buttons or checkboxes, or a
613
- * dropdown (the block's `display`; a multiple choice is never a dropdown); a date or a number as
614
- * its fields (./answers.js).
657
+ * dropdown (the block's `display`; a multiple choice is never a dropdown), or a single choice as
658
+ * picture cards (./answers.js); a date or a number as its fields (./answers.js).
615
659
  */
616
660
  function question(block) {
617
661
  const q = form.questions.find((x) => x.key === block.question);
@@ -620,11 +664,23 @@ export function mountForm(o) {
620
664
  set.dataset.question = q.key;
621
665
  set.append(el('legend', '', q.label ?? ''));
622
666
  const chosen = state.answers[q.key] ?? [];
623
- if (typed(q)) {
667
+ // Picture cards are a single choice's: any other question shows as it would without them.
668
+ const cards = block.display === 'cards' && q.type === 'one';
669
+ if (typed(q) || cards) {
624
670
  // Without its chunk the question says so, and the form goes on without its answer: every
625
671
  // question is optional.
626
672
  fields.then((m) => {
627
673
  if (!m) return set.append(el('p', 'sm', COPY.unavailable));
674
+ if (cards)
675
+ return m.answerCards(
676
+ set,
677
+ q,
678
+ block,
679
+ el,
680
+ chosen,
681
+ (p) => visual(p, 'qi', false, cardPart(state.size, q), '50vw'),
682
+ root,
683
+ );
628
684
  m.answerFields(set, q, el, chosen[0], lang);
629
685
  readers.set(set, () => m.answerOf(set, q, undefined, lang));
630
686
  });
@@ -649,12 +705,14 @@ export function mountForm(o) {
649
705
  }
650
706
  if (display !== 'buttons') set.className = 'ls';
651
707
  for (const x of options) {
708
+ const chip = el('label', display === 'buttons' ? 'ch' : 'c');
709
+ // The same input as rules.js choiceInput builds, written out: importing it costs the
710
+ // renderer bytes it does not have.
652
711
  const input = el('input');
653
- input.value = x.value;
654
- input.checked = chosen.includes(x.value);
655
712
  input.type = many ? 'checkbox' : 'radio';
656
713
  input.name = `q_${q.key}`;
657
- const chip = el('label', display === 'buttons' ? 'ch' : 'c');
714
+ input.value = x.value;
715
+ input.checked = chosen.includes(x.value);
658
716
  chip.append(input, el('span', '', x.label ?? x.value));
659
717
  set.append(chip);
660
718
  }
@@ -699,21 +757,23 @@ export function mountForm(o) {
699
757
  }
700
758
  }
701
759
 
760
+ /**
761
+ * A countdown's place: the answers chunk draws its label and timer into it and ticks it
762
+ * (./countdown.js), showing it, and hides it again once a deadline passes. Hidden until then, so
763
+ * a chunk that never comes leaves nothing behind, not an empty timer.
764
+ */
702
765
  function countdown(block) {
703
766
  // A claim with no code has nothing to hurry for.
704
767
  if (state.claim && !state.claim.code) return null;
705
- const node = el('p', 'tm');
706
- node.setAttribute('role', 'timer');
707
- const ends = Date.now() + Math.max(0, Number(block.minutes) || 0) * 60_000;
708
- const tick = () => {
709
- const left = Math.max(0, Math.round((ends - Date.now()) / 1000));
710
- node.textContent = `${String(Math.floor(left / 60)).padStart(2, '0')}:${String(left % 60).padStart(2, '0')}`;
711
- if (left === 0) clearInterval(id);
712
- };
713
- const id = setInterval(tick, 1000);
714
- state.timers.push(id);
715
- tick();
716
- return node;
768
+ const box = el('div', 'cd');
769
+ box.hidden = true;
770
+ fields.then(
771
+ (m) =>
772
+ m &&
773
+ box.isConnected &&
774
+ state.timers.push(m.countdown(box, block, preview, el)),
775
+ );
776
+ return box;
717
777
  }
718
778
 
719
779
  /** A link that opens `href`; a preview's goes nowhere. */
@@ -721,8 +781,9 @@ export function mountForm(o) {
721
781
  node.href = href;
722
782
  node.rel = 'noopener';
723
783
  node.addEventListener('click', (e) => {
724
- if (preview) e.preventDefault();
725
- else engaged('click');
784
+ if (preview) return e.preventDefault();
785
+ engaged('click');
786
+ leaving();
726
787
  });
727
788
  return node;
728
789
  }
@@ -1137,54 +1198,76 @@ export function mountForm(o) {
1137
1198
  (i) => i.value,
1138
1199
  ),
1139
1200
  ].filter(Boolean);
1140
- if (values.length) state.answers[key] = values;
1141
- else delete state.answers[key];
1201
+ if (values.length) {
1202
+ state.answers[key] = values;
1203
+ state.answeredOn[key] = state.step.id;
1204
+ } else delete state.answers[key];
1142
1205
  }
1143
1206
  }
1144
1207
 
1145
- /** After the sign-up, an answer the gateway did not carry is the page's to report. */
1208
+ /**
1209
+ * An answer the gateway did not carry is the page's to report: after the sign-up, or, on a form
1210
+ * that collects nothing (a quiz), as each step's answers are given.
1211
+ */
1146
1212
  function stated() {
1147
- const answers = [];
1148
- for (const [question, values] of Object.entries(state.answers)) {
1149
- const marker = `${question}=${values.join(',')}`;
1150
- if (state.sent.has(marker)) continue;
1151
- state.sent.add(marker);
1152
- answers.push({ question, values });
1153
- }
1154
- const step = state.step.id;
1213
+ const fresh = Object.entries(state.answers)
1214
+ .map(([question, values]) => ({ question, values }))
1215
+ .filter((a) => !state.sent.has(marker(a)));
1216
+ // Each answer goes with the step it was given on (collect records it), maybe an earlier one.
1217
+ const stepOf = ({ question }) => state.answeredOn[question];
1155
1218
  // After the email step, the gateway relays them to that address's profile and records them.
1156
- if (state.signedUp && answers.length)
1157
- return void api.answers({
1158
- anonymousId: access.anonymousId(),
1159
- sessionId: access.sessionId() ?? undefined,
1160
- slot: decision.slot,
1161
- arm: decision.arm ?? undefined,
1162
- decision_id: decision.decision_id ?? undefined,
1163
- step,
1164
- ...state.signedUp,
1165
- answers,
1166
- context: pageContext(),
1167
- });
1168
- if (access.analytics())
1169
- for (const answer of answers)
1170
- client.preferencesStated({
1171
- ...answer,
1172
- source: 'onsite',
1219
+ if (state.signedUp) {
1220
+ for (const step of new Set(fresh.map(stepOf))) {
1221
+ const answers = fresh.filter((a) => stepOf(a) === step);
1222
+ for (const answer of answers) state.sent.add(marker(answer));
1223
+ api.answers({
1224
+ anonymousId: access.anonymousId(),
1225
+ sessionId: access.sessionId() ?? undefined,
1173
1226
  slot: decision.slot,
1174
- arm: decision.arm ?? null,
1175
- decision_id: decision.decision_id ?? null,
1227
+ arm: decision.arm ?? undefined,
1228
+ decision_id: decision.decision_id ?? undefined,
1176
1229
  step,
1230
+ ...state.signedUp,
1231
+ answers,
1232
+ context: pageContext(),
1177
1233
  });
1234
+ }
1235
+ return;
1236
+ }
1237
+ // Kept back without analytics consent, so a later grant still reports it.
1238
+ if (!access.analytics()) return;
1239
+ for (const answer of fresh) {
1240
+ state.sent.add(marker(answer));
1241
+ client.preferencesStated({
1242
+ ...answer,
1243
+ source: 'onsite',
1244
+ slot: decision.slot,
1245
+ arm: decision.arm ?? null,
1246
+ decision_id: decision.decision_id ?? null,
1247
+ step: stepOf(answer),
1248
+ });
1249
+ }
1178
1250
  }
1179
1251
 
1252
+ const marker = ({ question, values }) => `${question}=${values.join(',')}`;
1253
+
1254
+ /** A quiz left by a link or closed keeps the answers given so far, as on moving on. */
1255
+ const leaving = () => {
1256
+ if (!asksNothing) return;
1257
+ collect();
1258
+ stated();
1259
+ };
1260
+
1180
1261
  const pageContext = () => ({
1181
1262
  page: { url: access.pageUrl() },
1182
1263
  locale: access.locale() ?? undefined,
1183
1264
  consent: access.consent(),
1184
1265
  });
1185
1266
 
1267
+ // Answers are reported as the form moves on: after a receipt, or on a form that collects nothing.
1268
+ // A sign-up form's answers before the sign-up travel with it.
1186
1269
  const advance = (explicit) => {
1187
- if (state.receipt && !preview) stated();
1270
+ if (!preview && (state.receipt || asksNothing)) stated();
1188
1271
  show(nextStep(state.step, state.answers, explicit));
1189
1272
  };
1190
1273
 
@@ -1428,14 +1511,19 @@ export function mountForm(o) {
1428
1511
  // A preview only moves between steps: nothing is checked, sent or claimed.
1429
1512
  if (preview) return advance(block.next);
1430
1513
  if (wrong) return say(wrong.error, wrong.input);
1514
+ // A quiz learns from clicks: moving on with an answer is a click.
1515
+ if (
1516
+ asksNothing &&
1517
+ state.step.blocks.some((b) => state.answers[b?.question])
1518
+ )
1519
+ engaged('click');
1431
1520
  if (block.action === 'apply') {
1432
1521
  o.emit?.('apply', {
1433
1522
  coupon: state.claim?.code ? { code: state.claim.code } : null,
1434
1523
  });
1435
1524
  engaged('click');
1436
- return nextStep(state.step, state.answers, block.next)
1437
- ? advance(block.next)
1438
- : close('done');
1525
+ // With no step after it, the form closes, done.
1526
+ return advance(block.next);
1439
1527
  }
1440
1528
  if (block.action === 'submit') return submit(block);
1441
1529
  if (block.action === 'spin') return spin(block);
@@ -1451,7 +1539,8 @@ export function mountForm(o) {
1451
1539
  act(first ?? { action: 'next' });
1452
1540
  }
1453
1541
 
1454
- function teardown() {
1542
+ /** Off the screen, with its listeners; `now` without the exit animation. */
1543
+ function teardown(now = false) {
1455
1544
  state.closed = true;
1456
1545
  for (const t of state.timers.splice(0)) stopTimer(t);
1457
1546
  document.removeEventListener('visibilitychange', visibility);
@@ -1464,7 +1553,7 @@ export function mountForm(o) {
1464
1553
  }
1465
1554
  host.remove();
1466
1555
  };
1467
- if (!dialog || reducedMotion()) return remove();
1556
+ if (now || !dialog || reducedMotion()) return remove();
1468
1557
  shell.classList.add('out');
1469
1558
  setTimeout(remove, EXIT_MS);
1470
1559
  }
@@ -1472,6 +1561,7 @@ export function mountForm(o) {
1472
1561
  function close(reason) {
1473
1562
  // A preview stays on screen: closing is the shopper's, not the trader's.
1474
1563
  if (state.closed || preview) return;
1564
+ leaving();
1475
1565
  if (!state.submitted && reason !== 'done') engaged('dismiss');
1476
1566
  counters.closed(form.slot);
1477
1567
  counters.tab?.(form.slot, false);
@@ -1553,13 +1643,19 @@ export function mountForm(o) {
1553
1643
  document.addEventListener('pointerup', up);
1554
1644
  });
1555
1645
 
1556
- show(
1557
- preview?.step && creative.steps.some((s) => s.id === preview.step)
1558
- ? preview.step
1559
- : creative.steps[0].id,
1560
- );
1561
- (o.container ?? document.body).append(host);
1562
- present();
1646
+ // A form that fails to open leaves nothing behind: no host, no listener on the document.
1647
+ try {
1648
+ show(
1649
+ preview?.step && creative.steps.some((s) => s.id === preview.step)
1650
+ ? preview.step
1651
+ : creative.steps[0].id,
1652
+ );
1653
+ (o.container ?? document.body).append(host);
1654
+ present();
1655
+ } catch (err) {
1656
+ teardown(true);
1657
+ throw err;
1658
+ }
1563
1659
  return {
1564
1660
  close,
1565
1661
  root,
@@ -35,7 +35,7 @@ export const OVERLAY = (format) => format !== 'inline';
35
35
  export const INTERRUPTS = (format) =>
36
36
  format === 'popup' || format === 'sheet' || format === 'fullscreen';
37
37
 
38
- const isObject = (v) =>
38
+ export const isObject = (v) =>
39
39
  v !== null && typeof v === 'object' && !Array.isArray(v);
40
40
  const strings = (v) =>
41
41
  Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
@@ -192,17 +192,20 @@ export function blockers(form, env) {
192
192
  export const byPriority = (a, b) => b.priority - a.priority;
193
193
 
194
194
  /**
195
- * The step after `step`: the button's own `next`, else the first `answers` rule the answers meet
196
- * (`"question=value"`), else the step's default. Null when there is none: the form is done.
195
+ * The step after `step`: the button's own `next`, else the first `answers` rule (`"question=value"`)
196
+ * an answer meets, else the step's default. Null when there is none: the form is done. Rules are
197
+ * tried in the order of the step's questions, then of each question's options (the order its
198
+ * answers are read in), never by the rules' own order: stored JSON does not keep an object's keys
199
+ * in order.
197
200
  */
198
201
  export function nextStep(step, answers, explicit) {
199
202
  if (typeof explicit === 'string' && explicit) return explicit;
200
203
  const rules = isObject(step?.next?.answers) ? step.next.answers : {};
201
- for (const [rule, target] of Object.entries(rules)) {
202
- const split = rule.indexOf('=');
203
- const values = answers[rule.slice(0, split)];
204
- if (split > 0 && values?.includes(rule.slice(split + 1))) return target;
205
- }
204
+ for (const b of step?.blocks ?? [])
205
+ for (const value of answers[b?.question] ?? []) {
206
+ const to = rules[`${b.question}=${value}`];
207
+ if (typeof to === 'string') return to;
208
+ }
206
209
  return typeof step?.next?.default === 'string' ? step.next.default : null;
207
210
  }
208
211
 
@@ -258,6 +261,30 @@ export function select(make, label, blank, pairs, chosen) {
258
261
  return s;
259
262
  }
260
263
 
264
+ /** A choice's options the renderer can show: each with a string value. */
265
+ export const optionsOf = (q) =>
266
+ (q.options ?? []).filter((x) => isObject(x) && typeof x.value === 'string');
267
+
268
+ /** One option of choice `q` as its input, named `q_<key>` and checked when it was chosen. */
269
+ export function choiceInput(make, q, x, chosen, type) {
270
+ const input = make('input');
271
+ input.type = type;
272
+ input.name = `q_${q.key}`;
273
+ input.value = x.value;
274
+ input.checked = chosen.includes(x.value);
275
+ return input;
276
+ }
277
+
278
+ /**
279
+ * How many of choice `q`'s picture cards a row holds where the form is wide (answers.js `.qc`), by
280
+ * its options: all of them up to four, else three or four so the last row is fullest. The renderer
281
+ * sizes their pictures by it too.
282
+ */
283
+ export const cardColumns = (q) => {
284
+ const n = q.options.length;
285
+ return n <= 4 ? n : n % 4 ? 3 : 4;
286
+ };
287
+
261
288
  /** Where a wheel must come to rest, in degrees clockwise, for prize `index` to sit under the top pointer. */
262
289
  export function restAngle(index, segments, turns = 5) {
263
290
  const size = 360 / segments;