@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.
@@ -30,8 +30,12 @@ const CARD_PX = { sm: 380, md: 480, lg: 880 };
30
30
  const ALIGNS = ['left', 'center', 'right'];
31
31
  const VARIANTS = { secondary: ' b2', link: ' no' };
32
32
  const EXIT_MS = 220;
33
+ /** The steps a submit or a spin can land on besides the next one (the creative's own, if it has them). */
34
+ const OUTCOMES = ['verify', 'already', 'held', 'fallback', 'success'];
33
35
  /** Waits before a claim's retries, for an API that answers 503 while it warms up. */
34
36
  const CLAIM_RETRY_MS = [500, 1500];
37
+ /** How long a sign-up's button stays busy for its claim, so a returning shopper is never moved on first. */
38
+ const HELD_WAIT_MS = 600;
35
39
  /** How long a wheel's prize is shown where it stopped before the next step: long enough to read. */
36
40
  const WON_MS = 1800;
37
41
  const COPY = {
@@ -42,7 +46,12 @@ const COPY = {
42
46
  failed: 'Something went wrong. Please try again.',
43
47
  busy: 'Too many tries. Please wait a moment and try again.',
44
48
  spin: 'Sign up first to spin.',
45
- held: 'Your code',
49
+ yours: 'Your code',
50
+ held: 'You’re already on the list',
51
+ resent: 'We’ve sent your code to your inbox again.',
52
+ inbox: 'Your code is in the email we sent when you first signed up.',
53
+ shop: 'Continue shopping',
54
+ closeOffer: 'Close offer',
46
55
  code: 'We’ll email your code to you.',
47
56
  claimed: 'This email address has already claimed its code.',
48
57
  copied: 'Copied',
@@ -87,6 +96,121 @@ export function layoutOf(step) {
87
96
  };
88
97
  }
89
98
 
99
+ /** The card's size: the theme's, else large for a split or an overlay. */
100
+ const sizeOf = (t, l) =>
101
+ oneOf(['sm', 'md', 'lg', 'full'], t.size, null) ??
102
+ (l.template === 'split' || l.template === 'overlay' ? 'lg' : 'md');
103
+
104
+ /**
105
+ * How wide an image shows, for the browser to pick from its srcset: the whole screen on a small
106
+ * one or a full-size form, else `part` of the card, a sheet or a teaser's tab.
107
+ */
108
+ const across = (mode, size, part = 1) => {
109
+ if (mode === 'fullscreen' || size === 'full') return '100vw';
110
+ const px =
111
+ mode === 'teaser'
112
+ ? 220
113
+ : mode === 'sheet'
114
+ ? 400
115
+ : (CARD_PX[size] ?? CARD_PX.md);
116
+ return `(max-width: 639px) 100vw, ${Math.round(px * part)}px`;
117
+ };
118
+
119
+ /**
120
+ * The smaller copies the upload made of a WebP image (`widths`, any of them, each at `-<w>w.webp`)
121
+ * and the image itself at its width, as a srcset; null when there are none to offer.
122
+ */
123
+ const srcset = (m, src, w) => {
124
+ if (!w || !Array.isArray(m.widths) || !src.endsWith('.webp')) return null;
125
+ const smaller = m.widths
126
+ .filter((x) => Number.isInteger(x) && x > 0 && x < w)
127
+ .sort((a, b) => a - b);
128
+ if (smaller.length === 0) return null;
129
+ return [
130
+ ...smaller.map((x) => `${src.slice(0, -5)}-${x}w.webp ${x}w`),
131
+ `${src} ${w}w`,
132
+ ].join(',');
133
+ };
134
+
135
+ /** The format a form shows in: an inline form's own, else its creative's, else the form's. */
136
+ const formatOf = (form, creative) =>
137
+ form.format === 'inline'
138
+ ? 'inline'
139
+ : creative.format && creative.format !== 'inline'
140
+ ? creative.format
141
+ : form.format;
142
+
143
+ const isVideo = (m) => m.kind === 'video' || m.t === 'video';
144
+
145
+ const VIDEO_TYPES = {
146
+ webm: 'video/webm',
147
+ mp4: 'video/mp4',
148
+ m4v: 'video/mp4',
149
+ mov: 'video/quicktime',
150
+ ogv: 'video/ogg',
151
+ };
152
+
153
+ /** A video file's type by its path's extension, never its query or fragment; undefined when unknown. */
154
+ const videoType = (href) =>
155
+ VIDEO_TYPES[href.split(/[?#]/)[0].split('.').pop().toLowerCase()];
156
+
157
+ /** A video's files: its asset and any other encodings of it (`sources`), WebM first. */
158
+ const videoFiles = (m, src) =>
159
+ [
160
+ ...new Set([
161
+ src,
162
+ ...(Array.isArray(m.sources) ? m.sources.map(assetHref) : []),
163
+ ]),
164
+ ]
165
+ .filter(Boolean)
166
+ .sort(
167
+ (a, b) =>
168
+ (videoType(b) === VIDEO_TYPES.webm) -
169
+ (videoType(a) === VIDEO_TYPES.webm),
170
+ );
171
+
172
+ /**
173
+ * Fetch and decode the pictures step `id` will show (its media, its image blocks, a video's
174
+ * poster), asked for exactly as the renderer asks (the same `srcset` and `sizes`, so the browser
175
+ * picks the same file), into the browser's cache. Resolves once each is decoded or has failed:
176
+ * the runtime opens a form when its first step's pictures are ready, or after a short wait.
177
+ */
178
+ export function warm(form, creative, id, seen = new Set()) {
179
+ const step = creative.steps.find((s) => s.id === id);
180
+ if (!step) return Promise.resolve();
181
+ const format = formatOf(form, creative);
182
+ const l = layoutOf(step);
183
+ const tab = format === 'teaser' && step === creative.steps[0];
184
+ const mode = format === 'teaser' && !tab ? 'popup' : format;
185
+ const size = sizeOf(creative.theme, l);
186
+ // A teaser's tab shows only its button, or its picture when that is all it is: the rest of the
187
+ // form opens as a popup, warmed with the popup's sizes once the tab is up (warmNext).
188
+ const wanted = tab
189
+ ? []
190
+ : step.blocks
191
+ .filter((b) => isObject(b) && (b.t === 'image' || b.t === 'video'))
192
+ .map((b) => [b, 1]);
193
+ if (l.media && !(tab && l.template !== 'image-only'))
194
+ wanted.push([l.media, l.template === 'split' ? 0.46 : 1]);
195
+ return Promise.all(
196
+ wanted.map(([m, part]) => {
197
+ const src = assetHref(isVideo(m) ? m.poster : m.asset);
198
+ if (!src) return null;
199
+ const img = document.createElement('img');
200
+ const set = !isVideo(m) && srcset(m, src, num(m.width, 1, 8192));
201
+ if (set) {
202
+ img.sizes = across(mode, size, part);
203
+ img.srcset = set;
204
+ }
205
+ const key = `${img.sizes} ${set || src}`;
206
+ if (seen.has(key)) return null;
207
+ seen.add(key);
208
+ img.src = src;
209
+ return img.decode?.().catch(() => {});
210
+ }),
211
+ );
212
+ }
213
+
90
214
  let ids = 0;
91
215
  const uid = (kind) => `hp-${kind}-${++ids}`;
92
216
 
@@ -210,12 +334,7 @@ export function mountForm(o) {
210
334
  ? form.sms_consent_text
211
335
  : form.consent_text;
212
336
  };
213
- const format =
214
- form.format === 'inline'
215
- ? 'inline'
216
- : creative.format && creative.format !== 'inline'
217
- ? creative.format
218
- : form.format;
337
+ const format = formatOf(form, creative);
219
338
  const t = creative.theme;
220
339
  const position = oneOf(
221
340
  POSITIONS[format] ?? [],
@@ -254,10 +373,9 @@ export function mountForm(o) {
254
373
  handle.setAttribute('aria-hidden', 'true');
255
374
  wrap.append(handle, media, body);
256
375
  shell.append(scrim, wrap);
257
- if (format !== 'inline') {
258
- const x = el('button', 'x');
376
+ const x = format !== 'inline' && el('button', 'x');
377
+ if (x) {
259
378
  x.type = 'button';
260
- x.setAttribute('aria-label', 'Close');
261
379
  x.addEventListener('click', () => close('close'));
262
380
  // After the form in the DOM, so the platform's first focus lands on the form itself.
263
381
  wrap.append(x);
@@ -280,6 +398,11 @@ export function mountForm(o) {
280
398
  ? { code: 'WELCOME15' }
281
399
  : null,
282
400
  claiming: null,
401
+ touched: false,
402
+ toWarm: new Set(),
403
+ // The page view's pictures warmed so far (the runtime's), so none is fetched twice.
404
+ warmed: o.warmed ?? new Set(),
405
+ warming: false,
283
406
  owed: false,
284
407
  submitted: false,
285
408
  pow: null,
@@ -307,10 +430,8 @@ export function mountForm(o) {
307
430
  const small = preview ? null : globalThis.matchMedia?.('(max-width: 639px)');
308
431
  const resize = () => {
309
432
  state.mobile = small?.matches === true;
310
- // A size kept from an earlier step is the old screen's.
311
- wrap.classList.remove('lk');
312
- wrap.style.removeProperty('min-width');
313
- wrap.style.removeProperty('min-height');
433
+ // A size or a top kept from an earlier step is the old screen's.
434
+ unanchor();
314
435
  classes();
315
436
  };
316
437
  if (small) {
@@ -375,43 +496,16 @@ export function mountForm(o) {
375
496
  document.addEventListener('visibilitychange', visibility);
376
497
 
377
498
  /**
378
- * How wide an image shows, for the browser to pick from its srcset: the whole screen on a small
379
- * one or a full-size form, else `part` of the card, a sheet or a teaser's tab.
499
+ * An image or video for an asset, its box reserved by its size and cropped to its focal point.
500
+ * `first`: on the form's first step, fetched at once and ahead of the page's other images; a
501
+ * later step's waits until it shows (and was warmed while the step before it showed).
380
502
  */
381
- const across = (part = 1) => {
382
- if (state.mode === 'fullscreen' || state.size === 'full') return '100vw';
383
- const px =
384
- state.mode === 'teaser'
385
- ? 220
386
- : state.mode === 'sheet'
387
- ? 400
388
- : (CARD_PX[state.size] ?? CARD_PX.md);
389
- return `(max-width: 639px) 100vw, ${Math.round(px * part)}px`;
390
- };
391
-
392
- /**
393
- * The smaller copies the upload made of a WebP image (`widths`, each at `-<w>w.webp`) and the
394
- * image itself at its width, as a srcset; null when there are none to offer.
395
- */
396
- const srcset = (m, src, w) => {
397
- if (!w || !Array.isArray(m.widths) || !src.endsWith('.webp')) return null;
398
- const smaller = m.widths.filter(
399
- (x) => Number.isInteger(x) && x > 0 && x < w,
400
- );
401
- if (smaller.length === 0) return null;
402
- return [
403
- ...smaller.map((x) => `${src.slice(0, -5)}-${x}w.webp ${x}w`),
404
- `${src} ${w}w`,
405
- ].join(',');
406
- };
407
-
408
- /** An image or video for an asset, its box reserved by its size and cropped to its focal point. */
409
- function visual(m, className, eager, part) {
503
+ function visual(m, className, first, part) {
410
504
  const src = assetHref(m.asset);
411
505
  if (!src) return null;
412
506
  const alt = typeof m.alt === 'string' ? m.alt : '';
413
507
  let node;
414
- if (m.kind === 'video' || m.t === 'video') {
508
+ if (isVideo(m)) {
415
509
  node = el('video', className);
416
510
  // The properties reflect to the attributes iOS needs before it plays inline.
417
511
  node.muted = node.defaultMuted = node.loop = node.playsInline = true;
@@ -420,17 +514,31 @@ export function mountForm(o) {
420
514
  if (alt) node.setAttribute('aria-label', alt);
421
515
  else node.setAttribute('aria-hidden', 'true');
422
516
  node.autoplay = !reducedMotion() && !preview;
423
- node.src = src;
517
+ // Not playing (reduced motion, a preview): its poster, else its first frame, never a blank
518
+ // box; with a poster the video itself is never downloaded.
519
+ node.preload = node.autoplay ? 'auto' : poster ? 'none' : 'metadata';
520
+ // WebM first where the browser plays it, MP4 for the rest.
521
+ const files = videoFiles(m, src);
522
+ if (files.length > 1)
523
+ for (const file of files) {
524
+ const source = el('source');
525
+ source.src = file;
526
+ const type = videoType(file);
527
+ if (type) source.type = type;
528
+ node.append(source);
529
+ }
530
+ else node.src = src;
424
531
  state.videos.push(node);
425
532
  } else {
426
533
  node = el('img', className);
427
534
  node.alt = alt;
428
535
  node.decoding = 'async';
429
- node.loading = eager ? 'eager' : 'lazy';
536
+ node.loading = first ? 'eager' : 'lazy';
537
+ if (first) node.fetchPriority = 'high';
430
538
  const set = srcset(m, src, num(m.width, 1, 8192));
431
539
  // sizes before srcset before src, so the browser fetches one file, the right one.
432
540
  if (set) {
433
- node.sizes = across(part);
541
+ node.sizes = across(state.mode, state.size, part);
434
542
  node.srcset = set;
435
543
  }
436
544
  node.src = src;
@@ -548,6 +656,9 @@ export function mountForm(o) {
548
656
  box.append(el('p', '', state.claim.held ? COPY.claimed : COPY.code));
549
657
  continue;
550
658
  }
659
+ // The prize the code is for, beside it: "You won!" alone does not say what.
660
+ if (state.claim.prize?.label)
661
+ box.append(el('p', '', state.claim.prize.label));
551
662
  const code = el('output');
552
663
  code.textContent = state.claim.code;
553
664
  const copy = el('button', 'btn cp');
@@ -617,7 +728,7 @@ export function mountForm(o) {
617
728
 
618
729
  /** An image or video block: its box, fit, radius and tallest height. */
619
730
  function picture(block) {
620
- const node = visual(block, 'bi', false);
731
+ const node = visual(block, 'bi', state.step === creative.steps[0]);
621
732
  if (!node) return null;
622
733
  node.style.objectFit = block.fit === 'contain' ? 'contain' : 'cover';
623
734
  const radius = num(block.radius, 0, 48);
@@ -746,17 +857,19 @@ export function mountForm(o) {
746
857
  function layout(l, tab) {
747
858
  media.replaceChildren();
748
859
  const m = l.media;
749
- const sized = oneOf(['sm', 'md', 'lg', 'full'], t.size, null);
750
- const fallback =
751
- l.template === 'split' || l.template === 'overlay' ? 'lg' : 'md';
752
860
  const bare = !m || (tab && l.template !== 'image-only');
753
- state.size = sized ?? fallback;
754
- wrap.className = `w t-${l.template} z-${sized ?? fallback}${l.side === 'right' ? ' right' : ''}${t.text_align === 'center' ? ' ta-center' : ''}${bare ? ' nm' : ''}`;
861
+ state.size = sizeOf(t, l);
862
+ wrap.className = `w t-${l.template} z-${state.size}${l.side === 'right' ? ' right' : ''}${t.text_align === 'center' ? ' ta-center' : ''}${bare ? ' nm' : ''}`;
755
863
  wrap.style.setProperty('--sc', String(l.scrim));
756
864
  body.className = `f pd-${l.padding}`;
757
865
  media.hidden = bare;
758
866
  if (media.hidden) return;
759
- const node = visual(m, '', true, l.template === 'split' ? 0.46 : 1);
867
+ const node = visual(
868
+ m,
869
+ '',
870
+ state.step === creative.steps[0],
871
+ l.template === 'split' ? 0.46 : 1,
872
+ );
760
873
  if (!node) return;
761
874
  if (node.width)
762
875
  wrap.style.setProperty('--ar', `${node.width} / ${node.height}`);
@@ -814,9 +927,15 @@ export function mountForm(o) {
814
927
  // entrance motion's transform is not in it.
815
928
  const was =
816
929
  moving && !preview
817
- ? { mode: state.mode, w: wrap.offsetWidth, h: wrap.offsetHeight }
930
+ ? {
931
+ mode: state.mode,
932
+ w: wrap.offsetWidth,
933
+ h: wrap.offsetHeight,
934
+ top: wrap.offsetTop,
935
+ }
818
936
  : null;
819
937
  state.step = step;
938
+ state.touched = false;
820
939
  for (const t of state.timers.splice(0)) clearInterval(t);
821
940
  state.codes = [];
822
941
  state.videos = [];
@@ -824,6 +943,7 @@ export function mountForm(o) {
824
943
  const asTab = format === 'teaser' && step === creative.steps[0];
825
944
  state.mode = format === 'teaser' && !asTab ? 'popup' : format;
826
945
  if (format === 'teaser') counters.tab?.(form.slot, asTab);
946
+ if (x) x.setAttribute('aria-label', asTab ? COPY.closeOffer : 'Close');
827
947
  classes();
828
948
  shell.removeAttribute('aria-labelledby');
829
949
  if (was) {
@@ -833,16 +953,15 @@ export function mountForm(o) {
833
953
  }
834
954
  const l = layoutOf(step);
835
955
  layout(l, asTab);
836
- // A step after another keeps at least the card's size, so a shorter one (the thanks after a
837
- // sign-up) never pulls the centred card, or the page under an inline form, out from under the
838
- // shopper: no layout shift, and no motion to reduce. A taller step still grows.
839
- if (was?.mode === state.mode && was.h > 0) {
956
+ // A popup's step after another takes its own height, its top kept (settle, below). Any other
957
+ // form keeps at least its size, so a shorter step never pulls a bar's or sheet's top edge, or
958
+ // the page under an inline form, out from under the shopper. A taller step still grows.
959
+ const settles = was?.mode === 'popup' && state.mode === 'popup';
960
+ unanchor();
961
+ if (was?.mode === state.mode && was.h > 0 && !settles) {
962
+ wrap.style.setProperty('--lw', `${was.w}px`);
963
+ wrap.style.setProperty('--lh', `${was.h}px`);
840
964
  wrap.classList.add('lk');
841
- wrap.style.minWidth = `${was.w}px`;
842
- wrap.style.minHeight = `${was.h}px`;
843
- } else {
844
- wrap.style.removeProperty('min-width');
845
- wrap.style.removeProperty('min-height');
846
965
  }
847
966
  body.replaceChildren();
848
967
  if (asTab && l.template !== 'image-only') body.append(tab(step));
@@ -868,12 +987,66 @@ export function mountForm(o) {
868
987
  if (shell.hasAttribute('aria-labelledby'))
869
988
  shell.removeAttribute('aria-label');
870
989
  else shell.setAttribute('aria-label', l.media?.alt?.trim?.() || 'Sign up');
990
+ if (settles && was.h > 0) settle(was);
871
991
  if (moving) present();
872
992
  visibility();
993
+ if (!preview) warmNext(step);
873
994
  // A screen reader hears the new step: focus moves to its heading when focus was in the form.
874
995
  if (refocus) body.querySelector('h2')?.focus();
875
996
  }
876
997
 
998
+ /** The card back to its own place and size: centred, its height its content's. */
999
+ function unanchor() {
1000
+ wrap.classList.remove('lk', 'an');
1001
+ wrap.style.removeProperty('--tp');
1002
+ }
1003
+
1004
+ /**
1005
+ * A popup's new step at its own height with its top where the last one's was, so only the bottom
1006
+ * edge moves: the Layout Instability API counts a box whose top-left moves, not one that grows or
1007
+ * shrinks, so the step change shifts nothing, and the card never keeps a tall step's empty space.
1008
+ * A step too tall for the room below that top is centred again, as a first step is.
1009
+ */
1010
+ function settle(was) {
1011
+ const h = wrap.offsetHeight;
1012
+ wrap.classList.add('an');
1013
+ const pad = wrap.offsetTop;
1014
+ const top = Math.max(0, was.top - pad);
1015
+ if (top + h > shell.clientHeight - 2 * pad) return unanchor();
1016
+ wrap.style.setProperty('--tp', `${top}px`);
1017
+ }
1018
+
1019
+ /**
1020
+ * The steps `step` can lead to have their pictures warmed while it shows, once the page has
1021
+ * loaded and the browser is idle, so the next step never opens on an empty picture either.
1022
+ */
1023
+ function warmNext(step) {
1024
+ const next = step.next ?? {};
1025
+ for (const id of [
1026
+ next.default,
1027
+ ...Object.values(isObject(next.answers) ? next.answers : {}),
1028
+ ...step.blocks.map((b) => b?.next),
1029
+ // Where a submit or a spin can land instead.
1030
+ ...OUTCOMES,
1031
+ ])
1032
+ if (typeof id === 'string') state.toWarm.add(id);
1033
+ if (state.warming) return;
1034
+ state.warming = true;
1035
+ const go = () => {
1036
+ state.warming = false;
1037
+ // A form closed, or taken off the page by the site, warms nothing more.
1038
+ if (!state.closed && host.isConnected)
1039
+ for (const id of state.toWarm) warm(form, creative, id, state.warmed);
1040
+ state.toWarm.clear();
1041
+ };
1042
+ const idle = () =>
1043
+ globalThis.requestIdleCallback
1044
+ ? requestIdleCallback(go, { timeout: 2000 })
1045
+ : setTimeout(go, 200);
1046
+ if (document.readyState === 'complete') idle();
1047
+ else window.addEventListener('load', idle, { once: true });
1048
+ }
1049
+
877
1050
  /** Open the dialog as the step's mode needs: modal for a popup, non-modal for the rest. */
878
1051
  function present() {
879
1052
  if (!dialog) return;
@@ -1016,7 +1189,7 @@ export function mountForm(o) {
1016
1189
  (res.status === 202 || res.status === 200) && OK.includes(status);
1017
1190
  // Closed while it was sent: a sign-up taken still counts, but nothing more is shown.
1018
1191
  if (state.closed && !taken) return;
1019
- busy(false);
1192
+ if (!taken) busy(false);
1020
1193
  // A contact the gateway refused is the shopper's to correct, in the SDK's own words for it.
1021
1194
  const field = res.status === 400 ? res.body?.field : null;
1022
1195
  if (field === 'email' || field === 'phone') return say(COPY[field], input);
@@ -1039,13 +1212,63 @@ export function mountForm(o) {
1039
1212
  // email the sign-up starts can carry it; it tells the page once the claim settles, with the
1040
1213
  // code or without.
1041
1214
  state.owed = channel === 'email' && form.prizes.length > 0;
1042
- if (state.owed) claim();
1043
- else o.emit?.('submitted', { coupon: null });
1215
+ // One attempt on the shopper's path (one that meets a warming API is retried behind it).
1216
+ const claimed = state.owed ? claim([]) : null;
1217
+ if (!claimed) o.emit?.('submitted', { coupon: null });
1218
+ // A returning shopper whose address claimed its code with an earlier sign-up goes straight to
1219
+ // the held step, not through the rest of the steps and a spin that cannot win: the button
1220
+ // stays busy for the claim's answer, as long as HELD_WAIT_MS at most (Klaviyo and Privy wait
1221
+ // on submit too). The gateway cannot say it in its own answer: claims are the public API's
1222
+ // (ADR 0021).
1223
+ const early =
1224
+ claimed &&
1225
+ (await Promise.race([
1226
+ claimed,
1227
+ new Promise((r) => setTimeout(r, HELD_WAIT_MS)),
1228
+ ]));
1229
+ if (state.closed) return;
1230
+ busy(false);
1231
+ if (early?.held) return show(ownStep('held'));
1044
1232
  // A step named for the outcome (`verify`, `already`) is shown for it; otherwise the next one.
1045
1233
  const special =
1046
1234
  status !== 'ok' && creative.steps.some((s) => s.id === status);
1047
- if (!state.closed)
1048
- show(special ? status : nextStep(step, state.answers, block.next));
1235
+ show(special ? status : nextStep(step, state.answers, block.next));
1236
+ // A held answer later than that moves the shopper only if they have not touched the step
1237
+ // they were moved on to; a spin started answers it itself.
1238
+ const next = state.step;
1239
+ claimed?.then((won) => {
1240
+ if (won?.held && !state.closed && state.step === next && !state.touched)
1241
+ show(ownStep('held'));
1242
+ });
1243
+ }
1244
+
1245
+ /**
1246
+ * The creative's step `id`, else the SDK's own plain one: a thanks with no prize (`fallback`); a
1247
+ * code this browser claimed on another form, with that code (`held`); or, for an address another
1248
+ * sign-up claimed with (`held`, no code), a welcome back. That code is never shown here: the
1249
+ * sign-up hands it to the destinations again, so the inbox has it when one carries it.
1250
+ */
1251
+ function ownStep(id) {
1252
+ if (creative.steps.some((s) => s.id === id)) return id;
1253
+ const blocks =
1254
+ id === 'fallback' || state.claim?.code
1255
+ ? [
1256
+ {
1257
+ t: 'heading',
1258
+ text: id === 'fallback' ? COPY.thanks : COPY.yours,
1259
+ },
1260
+ { t: 'code' },
1261
+ ]
1262
+ : [
1263
+ { t: 'heading', text: COPY.held },
1264
+ {
1265
+ t: 'text',
1266
+ text: form.code_relayed === true ? COPY.resent : COPY.inbox,
1267
+ },
1268
+ { t: 'button', label: COPY.shop, action: 'close' },
1269
+ ];
1270
+ creative.steps.push({ id, blocks });
1271
+ return id;
1049
1272
  }
1050
1273
 
1051
1274
  /** Tells the page, once, that the sign-up was taken: with the claim's code, or with none. */
@@ -1058,11 +1281,11 @@ export function mountForm(o) {
1058
1281
  }
1059
1282
 
1060
1283
  /** The form's claim, made once: a failed one is made again by the next spin. */
1061
- function claim() {
1284
+ function claim(retries = CLAIM_RETRY_MS) {
1062
1285
  const receipt = state.receipt;
1063
1286
  state.claiming ??= (async () => {
1064
1287
  let res;
1065
- for (const ms of [0, ...CLAIM_RETRY_MS]) {
1288
+ for (const ms of [0, ...retries]) {
1066
1289
  if (ms) await new Promise((r) => setTimeout(r, ms));
1067
1290
  res = await api.claim({
1068
1291
  slot: decision.slot,
@@ -1071,13 +1294,20 @@ export function mountForm(o) {
1071
1294
  });
1072
1295
  if (res.status !== 503) break;
1073
1296
  }
1074
- const won = res.body;
1297
+ let won = res.body;
1075
1298
  const fallback = won?.fallback === true && !won.code;
1076
1299
  if (res.status !== 200 || !(isObject(won?.prize) || fallback)) {
1077
1300
  state.claiming = null;
1301
+ // A single attempt that met a warming API is made again with its retries, which tell
1302
+ // the page; any other failure is final until a spin asks again.
1303
+ if (!retries.length && res.status === 503) return claim();
1078
1304
  settled(null);
1079
1305
  return null;
1080
1306
  }
1307
+ // The browser that claimed the code proved it is the shopper's when it won it: a held claim
1308
+ // shows it the code it kept, as a reminder would, rather than a welcome back.
1309
+ const kept = won.held && !won.code ? counters.code?.() : null;
1310
+ if (kept) won = { ...won, code: kept };
1081
1311
  state.claim = won;
1082
1312
  if (typeof won.code === 'string') counters.code?.(won.code);
1083
1313
  settled(won);
@@ -1119,15 +1349,9 @@ export function mountForm(o) {
1119
1349
  // No code left: the creative's `fallback` step, else a plain thanks, never a prize "won". A
1120
1350
  // prize held: its `held` step, else a plain one with the code block.
1121
1351
  const special = fallback ? 'fallback' : index < 0 ? 'held' : null;
1122
- if (special && !creative.steps.some((s) => s.id === special))
1123
- creative.steps.push({
1124
- id: special,
1125
- blocks: [
1126
- { t: 'heading', text: fallback ? COPY.thanks : COPY.held },
1127
- { t: 'code' },
1128
- ],
1129
- });
1130
- const to = special ?? nextStep(state.step, state.answers, block.next);
1352
+ const to = special
1353
+ ? ownStep(special)
1354
+ : nextStep(state.step, state.answers, block.next);
1131
1355
  if (to) show(to);
1132
1356
  else fillCodes();
1133
1357
  }
@@ -1189,14 +1413,61 @@ export function mountForm(o) {
1189
1413
  o.onClose?.(reason);
1190
1414
  }
1191
1415
 
1416
+ // Whether the shopper has done anything on the step on screen: a late claim never moves them.
1417
+ for (const type of ['pointerdown', 'keydown', 'input'])
1418
+ shell.addEventListener(type, () => {
1419
+ state.touched = true;
1420
+ });
1192
1421
  shell.addEventListener('cancel', (e) => {
1193
1422
  e.preventDefault();
1194
1423
  close('esc');
1195
1424
  });
1196
1425
  shell.addEventListener('keydown', (e) => {
1197
1426
  if (e.key === 'Escape' && !state.modal && format !== 'inline') close('esc');
1427
+ if (e.key === 'Tab' && state.modal) trap(e);
1198
1428
  });
1199
1429
 
1430
+ /**
1431
+ * Tab and Shift-Tab cycle inside a modal form. showModal() makes the page inert, but the browser
1432
+ * still tabs out of the dialog to the document (and its own chrome) before coming back; the
1433
+ * APG's modal dialog pattern keeps focus in the dialog, so the last control leads to the first.
1434
+ * The stops are the ones Tab reaches now: shown and enabled, and of a radio group only its
1435
+ * checked button, or its first. From anything else (the heading a step focuses), Tab goes to the
1436
+ * first stop after it and Shift-Tab to the last before it.
1437
+ */
1438
+ function trap(e) {
1439
+ const checked = [...root.querySelectorAll('input[type=radio]:checked')];
1440
+ const groups = new Set();
1441
+ const stops = [
1442
+ ...root.querySelectorAll('a[href],button,input,select,textarea'),
1443
+ ].filter((n) => {
1444
+ if (n.disabled || !n.getClientRects().length) return false;
1445
+ if (n.type !== 'radio') return true;
1446
+ const on = checked.find((r) => r.name === n.name);
1447
+ if (on ? n !== on : groups.has(n.name)) return false;
1448
+ groups.add(n.name);
1449
+ return true;
1450
+ });
1451
+ if (!stops.length) return;
1452
+ const back = e.shiftKey;
1453
+ let at = root.activeElement;
1454
+ if (at?.type === 'radio')
1455
+ at = stops.find((n) => n.type === 'radio' && n.name === at.name) ?? at;
1456
+ const i = stops.indexOf(at);
1457
+ let to = null;
1458
+ if (i < 0) {
1459
+ const after = (n) => at && at.compareDocumentPosition(n) & 4; // DOCUMENT_POSITION_FOLLOWING
1460
+ to = back
1461
+ ? (stops.findLast((n) => !after(n)) ?? stops.at(-1))
1462
+ : (stops.find(after) ?? stops[0]);
1463
+ } else if (back ? i === 0 : i === stops.length - 1)
1464
+ to = stops.at(back ? -1 : 0);
1465
+ if (to) {
1466
+ e.preventDefault();
1467
+ to.focus();
1468
+ }
1469
+ }
1470
+
1200
1471
  // Drag a bottom sheet down by its handle to close it.
1201
1472
  handle.addEventListener('pointerdown', (e) => {
1202
1473
  if (!state.mobile || state.mode !== 'sheet') return;
@@ -1229,6 +1500,8 @@ export function mountForm(o) {
1229
1500
  host,
1230
1501
  show,
1231
1502
  step: () => state.step?.id ?? null,
1503
+ /** Whether the form is a modal on screen now: a popup, or a teaser opened from its tab. */
1504
+ modal: () => !state.closed && state.modal,
1232
1505
  destroy: () => !state.closed && teardown(),
1233
1506
  };
1234
1507
  }