@thg-agentic/hyper-personalisation-web 3.22.0 → 3.23.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.
@@ -0,0 +1,1234 @@
1
+ /**
2
+ * One form on screen: a shadow root on a host element, the creative's steps as DOM (never
3
+ * innerHTML), and the capture, claim and engagement calls its buttons make.
4
+ *
5
+ * Popup and fullscreen are a native `<dialog>` opened with showModal(), so the platform traps focus,
6
+ * makes the page inert, closes on Esc and restores focus; the dialog is a full-viewport layer
7
+ * holding its own scrim and the card. Sheet, bar and a teaser's tab are non-modal dialogs that never
8
+ * trap focus; a teaser's later steps open as a popup. Inline renders into the page's own element.
9
+ * A preview (./preview.js) renders the same DOM inside the console's box, inert but for its steps.
10
+ */
11
+
12
+ import { solve } from './pow.js';
13
+ import { assetHref, httpsHref, richText } from './rich-text.js';
14
+ import { CALLING_CODES, e164, isEmail, nextStep, select } from './rules.js';
15
+ import { CSS, FONTS } from './styles.js';
16
+
17
+ const HEX = /^#(?:[0-9a-f]{3}){1,2}$/i;
18
+ const MODAL = ['popup', 'fullscreen'];
19
+ const OK = ['ok', 'verify', 'already'];
20
+ const TEMPLATES = ['stack', 'split', 'overlay', 'image-only'];
21
+ const POSITIONS = {
22
+ bar: ['bottom', 'top'],
23
+ sheet: ['bottom-right', 'bottom-left', 'bottom'],
24
+ teaser: ['bottom-left', 'bottom-right', 'left', 'right'],
25
+ };
26
+ const TYPE_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'display'];
27
+ const SPACERS = { xs: 4, sm: 8, md: 16, lg: 28, xl: 44 };
28
+ /** The card's widest, by its size (styles.js `.w`, `.z-*`), for an image's `sizes`. */
29
+ const CARD_PX = { sm: 380, md: 480, lg: 880 };
30
+ const ALIGNS = ['left', 'center', 'right'];
31
+ const VARIANTS = { secondary: ' b2', link: ' no' };
32
+ const EXIT_MS = 220;
33
+ /** Waits before a claim's retries, for an API that answers 503 while it warms up. */
34
+ const CLAIM_RETRY_MS = [500, 1500];
35
+ /** How long a wheel's prize is shown where it stopped before the next step: long enough to read. */
36
+ const WON_MS = 1800;
37
+ const COPY = {
38
+ email: 'Enter a valid email address.',
39
+ phone: 'Enter a valid mobile number.',
40
+ intl: 'Enter a valid mobile number, starting with + and its country code.',
41
+ consent: 'Tick the box to sign up.',
42
+ failed: 'Something went wrong. Please try again.',
43
+ busy: 'Too many tries. Please wait a moment and try again.',
44
+ spin: 'Sign up first to spin.',
45
+ held: 'Your code',
46
+ code: 'We’ll email your code to you.',
47
+ claimed: 'This email address has already claimed its code.',
48
+ copied: 'Copied',
49
+ thanks: 'Thanks, you’re signed up',
50
+ unavailable: 'Not available right now.',
51
+ won: 'You won',
52
+ };
53
+
54
+ const isObject = (v) =>
55
+ v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ const oneOf = (list, v, fallback) => (list.includes(v) ? v : fallback);
57
+ const num = (v, lo, hi) =>
58
+ Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : null;
59
+ /** A date or a number: its fields are ./answers.js's. */
60
+ const typed = (q) => q.type === 'date' || q.type === 'number';
61
+ const reducedMotion = () =>
62
+ globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
63
+
64
+ /** A step's layout, normalised: the earlier `layout.image` reads as a split or stack's media. */
65
+ export function layoutOf(step) {
66
+ const l = isObject(step.layout) ? step.layout : {};
67
+ let media = isObject(l.media) ? l.media : null;
68
+ let template = oneOf(TEMPLATES, l.template, 'stack');
69
+ let side = media?.side === 'right' ? 'right' : 'left';
70
+ if (!media && isObject(l.image) && l.image.side !== 'none') {
71
+ media = l.image;
72
+ if (l.image.side !== 'top') {
73
+ template = 'split';
74
+ side = l.image.side === 'right' ? 'right' : 'left';
75
+ }
76
+ }
77
+ if (!media || !assetHref(media.asset)) {
78
+ media = null;
79
+ template = 'stack';
80
+ }
81
+ return {
82
+ template,
83
+ media,
84
+ side,
85
+ scrim: num(l.scrim, 0, 0.9) ?? 0.45,
86
+ padding: oneOf(['none', 'sm', 'md', 'lg'], l.padding, 'md'),
87
+ };
88
+ }
89
+
90
+ let ids = 0;
91
+ const uid = (kind) => `hp-${kind}-${++ids}`;
92
+
93
+ function el(tag, className, text) {
94
+ const node = document.createElement(tag);
95
+ if (className) node.className = className;
96
+ if (text !== undefined) node.append(richText(text));
97
+ return node;
98
+ }
99
+
100
+ const rgb = (hex) => {
101
+ const h =
102
+ hex.length === 4
103
+ ? [...hex.slice(1)].map((c) => c + c).join('')
104
+ : hex.slice(1);
105
+ return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16));
106
+ };
107
+
108
+ /** WCAG 2.x relative luminance of a hex colour. */
109
+ const luminance = (hex) => {
110
+ const [r, g, b] = rgb(hex).map((c) => {
111
+ const s = c / 255;
112
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
113
+ });
114
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
115
+ };
116
+
117
+ /**
118
+ * The text colour on `hex`: white when it reaches `min` (WCAG: 4.5 for text, 3 for large text,
119
+ * which a button's label is), as brands almost always set it; otherwise `#111` or `#fff`,
120
+ * whichever contrasts more.
121
+ */
122
+ export const onColour = (hex, min = 4.5) => {
123
+ const l = luminance(hex) + 0.05;
124
+ const white = 1.05 / l;
125
+ if (white >= min) return '#fff';
126
+ return l / (luminance('#111') + 0.05) > white ? '#111' : '#fff';
127
+ };
128
+
129
+ function theme(host, t) {
130
+ const set = (name, value) => value && host.style.setProperty(name, value);
131
+ const hex = (v) => (HEX.test(v ?? '') ? v : null);
132
+ set('--hp-accent', hex(t.accent));
133
+ set('--hp-bg', hex(t.background));
134
+ set('--hp-text', hex(t.text));
135
+ const radius = num(t.radius, 0, 48);
136
+ if (radius !== null) set('--hp-radius', `${radius}px`);
137
+ // The site's own font (its @font-face rules reach the shadow root), a named system stack, or
138
+ // for anything else, a console font asset included, the default stack.
139
+ set(
140
+ '--hp-font',
141
+ t.font === 'site'
142
+ ? host.ownerDocument.defaultView.getComputedStyle(
143
+ host.parentElement ?? host.ownerDocument.body,
144
+ ).fontFamily
145
+ : Object.hasOwn(FONTS, t.font)
146
+ ? FONTS[t.font]
147
+ : null,
148
+ );
149
+ if (hex(t.accent)) {
150
+ set('--hp-on-accent', onColour(t.accent, 3));
151
+ set('--hp-on-accent-text', onColour(t.accent));
152
+ }
153
+ // On a dark form a field stays light, so it never reads as a second button.
154
+ if (hex(t.background) && luminance(t.background) < 0.18) {
155
+ set('--hp-field', '#fff');
156
+ set('--hp-field-text', '#111');
157
+ }
158
+ // The page behind is dimmed in the brand's own ink, the darker of its text and background,
159
+ // unless the creative names a colour.
160
+ const overlay = isObject(t.overlay) ? t.overlay : {};
161
+ const ink = [hex(t.text), hex(t.background)]
162
+ .filter(Boolean)
163
+ .sort((a, b) => luminance(a) - luminance(b))[0];
164
+ const [r, g, b] = rgb(hex(overlay.colour) ?? ink ?? '#0b0b0f');
165
+ set('--hp-ov', `rgb(${r} ${g} ${b} / ${num(overlay.opacity, 0, 1) ?? 0.6})`);
166
+ }
167
+
168
+ function styles(root) {
169
+ try {
170
+ const sheet = new CSSStyleSheet();
171
+ sheet.replaceSync(CSS);
172
+ root.adoptedStyleSheets = [sheet];
173
+ } catch {
174
+ const style = document.createElement('style');
175
+ style.textContent = CSS;
176
+ root.append(style);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Render `creative` for `form` and run it until it closes.
182
+ *
183
+ * @param {object} o
184
+ * @param {object} o.form The form from the settings (rules.readForm).
185
+ * @param {object} o.creative readCreative(decision.payload).
186
+ * @param {{ slot: string, arm?: string|null, decision_id?: string|null }} o.decision
187
+ * @param {object} o.access The client's onsite accessor.
188
+ * @param {object} o.client The SDK client, for its events.
189
+ * @param {ReturnType<import('./api.js').createApi>} o.api
190
+ * @param {ReturnType<import('./counters.js').createCounters>} o.counters
191
+ * @param {() => Promise<{ mountWheel: Function }>} o.loadWheel
192
+ * @param {() => Promise<{ answerFields: Function, answerOf: Function }>} o.loadAnswers The fields of
193
+ * a date or number question (./answers.js), loaded once as the form mounts when it asks one.
194
+ * @param {Element|null} [o.container] The page's element, for an inline form.
195
+ * @param {(reason: string) => void} [o.onClose]
196
+ * @param {(name: 'submitted'|'apply', detail: object) => void} [o.emit] To `hp.onsite.on` listeners.
197
+ * @param {string|null} [o.coupon] A code the form starts with: an unused one to remind of.
198
+ * @param {() => number|null} [o.basket] The basket's value, for a progress block.
199
+ * @param {{ device: 'desktop'|'mobile', step?: string }} [o.preview] Render inside `container`,
200
+ * inert but for moving between steps: no network, no events, no counters.
201
+ */
202
+ export function mountForm(o) {
203
+ const { form, creative, decision, access, client, api, counters } = o;
204
+ const preview = o.preview ?? null;
205
+ // A step's first field is what it submits; texts are agreed to in words that name texts. A step
206
+ // with no field shows the email words, or the SMS ones on a form that collects only texts.
207
+ const consentFor = (step) => {
208
+ const field = step.blocks.find((b) => b?.t === 'email' || b?.t === 'phone');
209
+ return field?.t === 'phone' || (!field && !form.consent_text)
210
+ ? form.sms_consent_text
211
+ : form.consent_text;
212
+ };
213
+ const format =
214
+ form.format === 'inline'
215
+ ? 'inline'
216
+ : creative.format && creative.format !== 'inline'
217
+ ? creative.format
218
+ : form.format;
219
+ const t = creative.theme;
220
+ const position = oneOf(
221
+ POSITIONS[format] ?? [],
222
+ t.position,
223
+ POSITIONS[format]?.[0],
224
+ );
225
+ const host = document.createElement('hp-onsite');
226
+ if (preview) host.setAttribute('preview', '');
227
+ theme(host, t);
228
+ const root = host.attachShadow({ mode: 'open' });
229
+ styles(root);
230
+
231
+ const dialog = format !== 'inline' && !preview;
232
+ const shell = el(dialog ? 'dialog' : format === 'inline' ? 'section' : 'div');
233
+ const scrim = el('div', 'ov');
234
+ const wrap = el('div', 'w');
235
+ const media = el('div', 'm');
236
+ if (preview) media.dataset.hpMedia = '';
237
+ /** The step's blocks: a new form for each step, so the browser reads it as new content, not moved. */
238
+ const blank = () => {
239
+ const f = el('form', 'f');
240
+ f.noValidate = true;
241
+ f.addEventListener('submit', submitted);
242
+ // A message about a field goes once the shopper changes it.
243
+ f.addEventListener('input', () => {
244
+ if (error.textContent && body.getAttribute('aria-busy') !== 'true')
245
+ say(null);
246
+ });
247
+ return f;
248
+ };
249
+ let body = blank();
250
+ const error = el('p', 'e');
251
+ error.id = uid('err');
252
+ error.setAttribute('aria-live', 'assertive');
253
+ const handle = el('div', 'hd');
254
+ handle.setAttribute('aria-hidden', 'true');
255
+ wrap.append(handle, media, body);
256
+ shell.append(scrim, wrap);
257
+ if (format !== 'inline') {
258
+ const x = el('button', 'x');
259
+ x.type = 'button';
260
+ x.setAttribute('aria-label', 'Close');
261
+ x.addEventListener('click', () => close('close'));
262
+ // After the form in the DOM, so the platform's first focus lands on the form itself.
263
+ wrap.append(x);
264
+ scrim.addEventListener('click', () => close('close'));
265
+ }
266
+ root.append(shell);
267
+
268
+ const state = {
269
+ step: null,
270
+ mode: format,
271
+ size: 'md',
272
+ modal: false,
273
+ mobile: preview?.device === 'mobile',
274
+ answers: {},
275
+ sent: new Set(),
276
+ receipt: null,
277
+ claim: o.coupon
278
+ ? { code: o.coupon }
279
+ : preview
280
+ ? { code: 'WELCOME15' }
281
+ : null,
282
+ claiming: null,
283
+ owed: false,
284
+ submitted: false,
285
+ pow: null,
286
+ wheel: null,
287
+ timers: [],
288
+ codes: [],
289
+ videos: [],
290
+ closed: false,
291
+ };
292
+
293
+ // The form's language, else the page's: a phone field's country, a birthday's months, money.
294
+ const lang = form.locale || document.documentElement.lang;
295
+ // ./answers.js, asked for once, as the form mounts, when it asks a date or a number; null when it
296
+ // could not load. Each of their fieldsets reads its answer with it.
297
+ const fields =
298
+ form.questions.some(typed) && o.loadAnswers().catch(() => null);
299
+ const readers = new WeakMap();
300
+
301
+ const classes = () => {
302
+ const button = isObject(t.button) ? t.button : {};
303
+ shell.className = `s ${state.mode}${position ? ` p-${position}` : ''} b-${oneOf(['pill', 'rounded', 'square'], button.shape, 'rounded')}${state.mobile ? ' mob' : ''}${preview ? ' pv' : ''}${button.style === 'outline' ? ' bo' : ''}${t.caps === true ? ' uc' : ''}`;
304
+ };
305
+
306
+ // Small screens by the viewport, a preview by its device.
307
+ const small = preview ? null : globalThis.matchMedia?.('(max-width: 639px)');
308
+ const resize = () => {
309
+ 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');
314
+ classes();
315
+ };
316
+ if (small) {
317
+ state.mobile = small.matches === true;
318
+ small.addEventListener?.('change', resize);
319
+ }
320
+
321
+ const engaged = (engagement, step) => {
322
+ if (preview || !decision.decision_id || !access.analytics()) return;
323
+ client.slotEngaged({
324
+ decision_id: decision.decision_id,
325
+ slot: decision.slot,
326
+ arm: decision.arm ?? null,
327
+ engagement,
328
+ step: step ?? state.step?.id ?? null,
329
+ });
330
+ };
331
+
332
+ /** A message under the field it is about (else after the step's blocks); none clears it. */
333
+ const say = (message, input) => {
334
+ error.className = 'e';
335
+ error.textContent = message ?? '';
336
+ for (const node of body.querySelectorAll('[aria-invalid]'))
337
+ node.removeAttribute('aria-invalid');
338
+ const at = input?.closest('label,fieldset');
339
+ if (at && body.contains(at)) at.after(error);
340
+ else body.append(error);
341
+ if (input) {
342
+ input.setAttribute('aria-invalid', 'true');
343
+ input.setAttribute('aria-describedby', error.id);
344
+ input.focus();
345
+ input.scrollIntoView?.({ block: 'nearest' });
346
+ }
347
+ };
348
+
349
+ const busy = (on) => {
350
+ body.setAttribute('aria-busy', String(on));
351
+ for (const b of body.querySelectorAll('button')) b.disabled = on;
352
+ };
353
+
354
+ /** The proof of work, started on the first focus of a contact field so it overlaps typing. */
355
+ const proof = () => {
356
+ if (preview) return Promise.resolve(null);
357
+ state.pow ??= api
358
+ .challenge()
359
+ .then((res) => (res.status === 200 ? solve(res.body) : null))
360
+ .catch(() => null);
361
+ return state.pow;
362
+ };
363
+
364
+ /** Videos play only without reduced motion, and pause while the page is hidden. */
365
+ const visibility = () => {
366
+ for (const v of state.videos) {
367
+ try {
368
+ if (document.hidden || reducedMotion()) v.pause();
369
+ else v.play()?.catch?.(() => {});
370
+ } catch {
371
+ /* a media element without playback */
372
+ }
373
+ }
374
+ };
375
+ document.addEventListener('visibilitychange', visibility);
376
+
377
+ /**
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.
380
+ */
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) {
410
+ const src = assetHref(m.asset);
411
+ if (!src) return null;
412
+ const alt = typeof m.alt === 'string' ? m.alt : '';
413
+ let node;
414
+ if (m.kind === 'video' || m.t === 'video') {
415
+ node = el('video', className);
416
+ // The properties reflect to the attributes iOS needs before it plays inline.
417
+ node.muted = node.defaultMuted = node.loop = node.playsInline = true;
418
+ const poster = assetHref(m.poster);
419
+ if (poster) node.poster = poster;
420
+ if (alt) node.setAttribute('aria-label', alt);
421
+ else node.setAttribute('aria-hidden', 'true');
422
+ node.autoplay = !reducedMotion() && !preview;
423
+ node.src = src;
424
+ state.videos.push(node);
425
+ } else {
426
+ node = el('img', className);
427
+ node.alt = alt;
428
+ node.decoding = 'async';
429
+ node.loading = eager ? 'eager' : 'lazy';
430
+ const set = srcset(m, src, num(m.width, 1, 8192));
431
+ // sizes before srcset before src, so the browser fetches one file, the right one.
432
+ if (set) {
433
+ node.sizes = across(part);
434
+ node.srcset = set;
435
+ }
436
+ node.src = src;
437
+ }
438
+ const w = num(m.width, 1, 8192);
439
+ const h = num(m.height, 1, 8192);
440
+ if (w && h) {
441
+ node.width = w;
442
+ node.height = h;
443
+ }
444
+ if (isObject(m.focal))
445
+ node.style.objectPosition = `${num(m.focal.x, 0, 100) ?? 50}% ${num(m.focal.y, 0, 100) ?? 50}%`;
446
+ return node;
447
+ }
448
+
449
+ function field(block, type) {
450
+ const label = el('label', 'l');
451
+ label.append(
452
+ el(
453
+ 'span',
454
+ 'lt',
455
+ block.label ?? (type === 'email' ? 'Email' : 'Mobile number'),
456
+ ),
457
+ );
458
+ const input = el('input', 'i');
459
+ input.type = type;
460
+ input.name = type === 'email' ? 'email' : 'phone';
461
+ input.autocomplete = input.inputMode = type;
462
+ input.required = true;
463
+ if (typeof block.placeholder === 'string')
464
+ input.placeholder = block.placeholder;
465
+ input.setAttribute('aria-describedby', error.id);
466
+ input.addEventListener('focus', proof, { once: true });
467
+ if (type !== 'tel') {
468
+ label.append(input);
469
+ return label;
470
+ }
471
+ // A country code picker, as Klaviyo's forms have: the block's country, else the region of
472
+ // the form's language, else none (a number then starts with +). A typed + or 00 wins over it.
473
+ input.id = `${error.id}-p`;
474
+ label.htmlFor = input.id;
475
+ const pick = select(
476
+ el,
477
+ 'Country code',
478
+ '+',
479
+ Object.entries(CALLING_CODES).map(([c, code]) => [c, `${c} +${code}`]),
480
+ (block.country || /-(\w\w)$/.exec(lang)?.[1] || '').toUpperCase(),
481
+ );
482
+ pick.classList.add('cc');
483
+ pick.name = 'hp_cc';
484
+ const row = el('span', 'ph');
485
+ row.append(pick, input);
486
+ label.append(row);
487
+ return label;
488
+ }
489
+
490
+ /**
491
+ * A question as its type says: a choice as buttons, a list of radio buttons or checkboxes, or a
492
+ * dropdown (the block's `display`; a multiple choice is never a dropdown); a date or a number as
493
+ * its fields (./answers.js).
494
+ */
495
+ function question(block) {
496
+ const q = form.questions.find((x) => x.key === block.question);
497
+ if (!q || (!typed(q) && !Array.isArray(q.options))) return null;
498
+ const set = el('fieldset');
499
+ set.dataset.question = q.key;
500
+ set.append(el('legend', '', q.label ?? ''));
501
+ const chosen = state.answers[q.key] ?? [];
502
+ if (typed(q)) {
503
+ // Without its chunk the question says so, and the form goes on without its answer: every
504
+ // question is optional.
505
+ fields.then((m) => {
506
+ if (!m) return set.append(el('p', 'sm', COPY.unavailable));
507
+ m.answerFields(set, q, el, chosen[0], lang);
508
+ readers.set(set, () => m.answerOf(set, q, undefined, lang));
509
+ });
510
+ return set;
511
+ }
512
+ const many = q.type === 'many';
513
+ const display = oneOf(['list', 'dropdown'], block.display, 'buttons');
514
+ const options = q.options.filter(
515
+ (x) => isObject(x) && typeof x.value === 'string',
516
+ );
517
+ if (display === 'dropdown' && !many) {
518
+ set.append(
519
+ select(
520
+ el,
521
+ q.label ?? q.key,
522
+ '—',
523
+ options.map((x) => [x.value, x.label ?? x.value]),
524
+ chosen[0],
525
+ ),
526
+ );
527
+ return set;
528
+ }
529
+ if (display !== 'buttons') set.className = 'ls';
530
+ for (const x of options) {
531
+ const input = el('input');
532
+ input.value = x.value;
533
+ input.checked = chosen.includes(x.value);
534
+ input.type = many ? 'checkbox' : 'radio';
535
+ input.name = `q_${q.key}`;
536
+ const chip = el('label', display === 'buttons' ? 'ch' : 'c');
537
+ chip.append(input, el('span', '', x.label ?? x.value));
538
+ set.append(chip);
539
+ }
540
+ return set;
541
+ }
542
+
543
+ function fillCodes() {
544
+ for (const box of state.codes) {
545
+ box.replaceChildren();
546
+ if (!state.claim) continue;
547
+ if (!state.claim.code) {
548
+ box.append(el('p', '', state.claim.held ? COPY.claimed : COPY.code));
549
+ continue;
550
+ }
551
+ const code = el('output');
552
+ code.textContent = state.claim.code;
553
+ const copy = el('button', 'btn cp');
554
+ copy.type = 'button';
555
+ copy.textContent = 'Copy code';
556
+ copy.addEventListener('click', () => {
557
+ navigator.clipboard
558
+ ?.writeText(state.claim.code)
559
+ .then(() => {
560
+ copy.textContent = COPY.copied;
561
+ })
562
+ .catch(() => {});
563
+ });
564
+ box.append(code, copy);
565
+ }
566
+ }
567
+
568
+ function countdown(block) {
569
+ // A claim with no code has nothing to hurry for.
570
+ if (state.claim && !state.claim.code) return null;
571
+ const node = el('p', 'tm');
572
+ node.setAttribute('role', 'timer');
573
+ const ends = Date.now() + Math.max(0, Number(block.minutes) || 0) * 60_000;
574
+ const tick = () => {
575
+ const left = Math.max(0, Math.round((ends - Date.now()) / 1000));
576
+ node.textContent = `${String(Math.floor(left / 60)).padStart(2, '0')}:${String(left % 60).padStart(2, '0')}`;
577
+ if (left === 0) clearInterval(id);
578
+ };
579
+ const id = setInterval(tick, 1000);
580
+ state.timers.push(id);
581
+ tick();
582
+ return node;
583
+ }
584
+
585
+ /** A link that opens `href`; a preview's goes nowhere. */
586
+ function link(node, href) {
587
+ node.href = href;
588
+ node.rel = 'noopener';
589
+ node.addEventListener('click', (e) => {
590
+ if (preview) e.preventDefault();
591
+ else engaged('click');
592
+ });
593
+ return node;
594
+ }
595
+
596
+ function button(block) {
597
+ const label = block.label ?? 'Continue';
598
+ if (block.action === 'link') {
599
+ const href = httpsHref(block.href);
600
+ return href
601
+ ? link(el('a', `btn${VARIANTS[block.variant] ?? ''}`, label), href)
602
+ : null;
603
+ }
604
+ const b = el('button', `btn${VARIANTS[block.variant] ?? ''}`, label);
605
+ b.type = block.action === 'submit' ? 'submit' : 'button';
606
+ if (b.type === 'button') b.addEventListener('click', () => act(block));
607
+ else b.dataset.submit = '';
608
+ return b;
609
+ }
610
+
611
+ /** Type size and alignment classes for a heading or text. */
612
+ const typeset = (node, block, size) => {
613
+ node.classList.add(`x-${oneOf(TYPE_SIZES, block.size, size)}`);
614
+ if (ALIGNS.includes(block.align)) node.classList.add(`a-${block.align}`);
615
+ return node;
616
+ };
617
+
618
+ /** An image or video block: its box, fit, radius and tallest height. */
619
+ function picture(block) {
620
+ const node = visual(block, 'bi', false);
621
+ if (!node) return null;
622
+ node.style.objectFit = block.fit === 'contain' ? 'contain' : 'cover';
623
+ const radius = num(block.radius, 0, 48);
624
+ if (radius !== null) node.style.borderRadius = `${radius}px`;
625
+ const tallest = num(block.max_height, 40, 800);
626
+ if (tallest !== null) node.style.maxHeight = `${tallest}px`;
627
+ if (!node.width && block.fit !== 'contain')
628
+ node.style.aspectRatio = '16 / 9';
629
+ if (block.t === 'video' || !httpsHref(block.href)) return node;
630
+ const a = link(el('a', 'bl'), httpsHref(block.href));
631
+ a.append(node);
632
+ return a;
633
+ }
634
+
635
+ /** Progress to a basket threshold, from the basket value the SDK keeps; re-read while shown. */
636
+ function progress(block) {
637
+ const threshold = num(block.threshold, 0.01, 1e7);
638
+ if (!threshold) return null;
639
+ const box = el('div', 'pg');
640
+ const label = el('p');
641
+ const track = el('div', 'tr');
642
+ const fill = el('i');
643
+ track.append(fill);
644
+ track.setAttribute('role', 'progressbar');
645
+ track.setAttribute('aria-valuemin', '0');
646
+ track.setAttribute('aria-valuemax', String(threshold));
647
+ box.append(label, track);
648
+ let money = (n) => n.toFixed(2);
649
+ try {
650
+ const format = new Intl.NumberFormat(lang || undefined, {
651
+ style: 'currency',
652
+ currency: block.currency,
653
+ });
654
+ money = (n) => format.format(n);
655
+ } catch {
656
+ /* an unknown currency: the bare amount */
657
+ }
658
+ const paint = () => {
659
+ const value = Math.max(0, Number(o.basket?.()) || 0);
660
+ const left = threshold - value;
661
+ label.textContent =
662
+ left > 0
663
+ ? String(block.text ?? '').replace('{amount}', money(left))
664
+ : (block.done ?? '');
665
+ fill.style.transform = `scaleX(${Math.min(1, value / threshold)})`;
666
+ track.setAttribute('aria-valuenow', String(Math.min(value, threshold)));
667
+ label.id ||= uid('pg');
668
+ track.setAttribute('aria-labelledby', label.id);
669
+ };
670
+ paint();
671
+ state.timers.push(setInterval(paint, 2000));
672
+ return box;
673
+ }
674
+
675
+ const BLOCKS = {
676
+ heading(block) {
677
+ const h = typeset(el('h2', '', block.text), block, 'xl');
678
+ h.id = uid('h');
679
+ h.tabIndex = -1;
680
+ if (!shell.hasAttribute('aria-labelledby')) {
681
+ shell.setAttribute('aria-labelledby', h.id);
682
+ // The heading takes a modal's first focus: read aloud, and no keyboard pops up. A form
683
+ // that does not trap focus never takes it from the page, nor scrolls it.
684
+ if (
685
+ state.step === creative.steps[0] &&
686
+ MODAL.includes(state.mode) &&
687
+ !preview
688
+ )
689
+ h.autofocus = true;
690
+ }
691
+ return h;
692
+ },
693
+ text: (block) => typeset(el('p', '', block.text), block, 'md'),
694
+ email: (block) => field(block, 'email'),
695
+ phone: (block) => field(block, 'tel'),
696
+ consent(block) {
697
+ const text = consentFor(state.step)?.text;
698
+ if (typeof text !== 'string') return null;
699
+ if (block.required !== true) return el('p', 'sm', text);
700
+ const label = el('label', 'c');
701
+ const box = el('input');
702
+ box.type = 'checkbox';
703
+ box.name = 'hp_consent';
704
+ box.required = true;
705
+ box.setAttribute('aria-describedby', error.id);
706
+ label.append(box, el('span', '', text));
707
+ return label;
708
+ },
709
+ question,
710
+ wheel() {
711
+ const box = el('div', 'wh');
712
+ o.loadWheel()
713
+ .then((m) => {
714
+ if (!state.closed) state.wheel = m.mountWheel(box, form.prizes);
715
+ })
716
+ .catch(() => {});
717
+ return box;
718
+ },
719
+ code() {
720
+ const box = el('div', 'k');
721
+ box.setAttribute('aria-live', 'polite');
722
+ state.codes.push(box);
723
+ return box;
724
+ },
725
+ button,
726
+ decline(block) {
727
+ const b = el('button', 'btn no', block.label ?? 'No thanks');
728
+ b.type = 'button';
729
+ b.addEventListener('click', () => close('decline'));
730
+ return b;
731
+ },
732
+ countdown,
733
+ progress,
734
+ image: picture,
735
+ video: picture,
736
+ spacer(block) {
737
+ const node = el('div', 'sp');
738
+ node.style.height = `${SPACERS[block.size] ?? SPACERS.md}px`;
739
+ node.setAttribute('aria-hidden', 'true');
740
+ return node;
741
+ },
742
+ divider: () => el('hr'),
743
+ };
744
+
745
+ /** The step's media: beside, above or behind the blocks, or the whole step as one button. */
746
+ function layout(l, tab) {
747
+ media.replaceChildren();
748
+ 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
+ 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' : ''}`;
755
+ wrap.style.setProperty('--sc', String(l.scrim));
756
+ body.className = `f pd-${l.padding}`;
757
+ media.hidden = bare;
758
+ if (media.hidden) return;
759
+ const node = visual(m, '', true, l.template === 'split' ? 0.46 : 1);
760
+ if (!node) return;
761
+ if (node.width)
762
+ wrap.style.setProperty('--ar', `${node.width} / ${node.height}`);
763
+ else wrap.style.removeProperty('--ar');
764
+ if (l.template !== 'image-only') {
765
+ if (node.tagName === 'IMG' && l.template === 'overlay') node.alt = '';
766
+ media.append(node);
767
+ return;
768
+ }
769
+ // One button (or link) the image names: a pure-creative gate or teaser.
770
+ const href = httpsHref(m.href);
771
+ const target = href ? link(el('a', 'io'), href) : el('button', 'io');
772
+ if (!href) {
773
+ target.type = 'button';
774
+ target.addEventListener('click', () => act({ action: 'next' }));
775
+ }
776
+ target.append(node);
777
+ media.append(target);
778
+ }
779
+
780
+ /** A teaser's first step: a tab that opens the rest of the form. */
781
+ function tab(step) {
782
+ const heading = step.blocks.find((b) => b?.t === 'heading');
783
+ const open = step.blocks.find(
784
+ (b) => b?.t === 'button' && b.action === 'next',
785
+ );
786
+ // The heading's words without its marks: a button holds no link.
787
+ const b = el('button', 'tb');
788
+ b.textContent = heading
789
+ ? el('b', '', heading.text).textContent
790
+ : (open?.label ?? 'Open');
791
+ b.type = 'button';
792
+ b.addEventListener('click', () => act(open ?? { action: 'next' }));
793
+ return b;
794
+ }
795
+
796
+ function show(id, skipped = new Set()) {
797
+ const step = creative.steps.find((s) => s.id === id);
798
+ if (!step) return close('done');
799
+ // Without analytics consent an answer is never kept, so the question is not asked.
800
+ if (
801
+ step.kind === 'question' &&
802
+ !preview &&
803
+ !access.analytics() &&
804
+ !skipped.has(step.id)
805
+ )
806
+ return show(nextStep(step, state.answers), skipped.add(step.id));
807
+ const moving = state.step !== null;
808
+ // Focus in the form, or dropped from it to the page (a button disabled while it sent, or
809
+ // taken away with its step), goes to the new step's heading below.
810
+ const refocus =
811
+ moving &&
812
+ (root.activeElement || document.activeElement === document.body);
813
+ // The card the shopper sees now, measured before the step changes; its layout box, so the
814
+ // entrance motion's transform is not in it.
815
+ const was =
816
+ moving && !preview
817
+ ? { mode: state.mode, w: wrap.offsetWidth, h: wrap.offsetHeight }
818
+ : null;
819
+ state.step = step;
820
+ for (const t of state.timers.splice(0)) clearInterval(t);
821
+ state.codes = [];
822
+ state.videos = [];
823
+ state.wheel = null;
824
+ const asTab = format === 'teaser' && step === creative.steps[0];
825
+ state.mode = format === 'teaser' && !asTab ? 'popup' : format;
826
+ if (format === 'teaser') counters.tab?.(form.slot, asTab);
827
+ classes();
828
+ shell.removeAttribute('aria-labelledby');
829
+ if (was) {
830
+ const next = blank();
831
+ body.replaceWith(next);
832
+ body = next;
833
+ }
834
+ const l = layoutOf(step);
835
+ 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) {
840
+ 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
+ }
847
+ body.replaceChildren();
848
+ if (asTab && l.template !== 'image-only') body.append(tab(step));
849
+ else
850
+ for (const [index, block] of step.blocks.entries()) {
851
+ const node =
852
+ isObject(block) && Object.hasOwn(BLOCKS, block.t)
853
+ ? BLOCKS[block.t](block)
854
+ : null;
855
+ if (!node) continue;
856
+ // An editor's selection layer finds each block by its place in the step.
857
+ if (preview) node.dataset.hpBlock = String(index);
858
+ body.append(node);
859
+ }
860
+ // A tick box sits directly above the step's submit button wherever the step lists it, so a
861
+ // shopper sees it before pressing, not only after being refused.
862
+ const go = body.querySelector('[data-submit]');
863
+ const tick = body.querySelector('[name=hp_consent]')?.parentNode;
864
+ if (go && tick) go.before(tick);
865
+ body.hidden = body.childElementCount === 0;
866
+ say(null);
867
+ fillCodes();
868
+ if (shell.hasAttribute('aria-labelledby'))
869
+ shell.removeAttribute('aria-label');
870
+ else shell.setAttribute('aria-label', l.media?.alt?.trim?.() || 'Sign up');
871
+ if (moving) present();
872
+ visibility();
873
+ // A screen reader hears the new step: focus moves to its heading when focus was in the form.
874
+ if (refocus) body.querySelector('h2')?.focus();
875
+ }
876
+
877
+ /** Open the dialog as the step's mode needs: modal for a popup, non-modal for the rest. */
878
+ function present() {
879
+ if (!dialog) return;
880
+ const modal = MODAL.includes(state.mode);
881
+ if (shell.open && state.modal === modal) return;
882
+ try {
883
+ if (shell.open) shell.close();
884
+ } catch {
885
+ /* already closed */
886
+ }
887
+ state.modal = modal;
888
+ shell.setAttribute('aria-modal', String(modal));
889
+ if (modal && typeof shell.showModal === 'function') shell.showModal();
890
+ else if (typeof shell.show === 'function') {
891
+ // A non-modal form never takes the shopper's focus from the page.
892
+ const was = document.activeElement;
893
+ shell.show();
894
+ if (was && was !== document.body) was.focus?.({ preventScroll: true });
895
+ else host.blur();
896
+ } else shell.setAttribute('open', '');
897
+ if (!modal || typeof shell.showModal !== 'function')
898
+ shell.setAttribute('role', 'dialog');
899
+ else shell.removeAttribute('role');
900
+ }
901
+
902
+ /**
903
+ * The answers given on this step, into the form's answers; a date or number that is not one
904
+ * the question takes is the shopper's to correct: `{ error, input }`.
905
+ */
906
+ function collect() {
907
+ for (const set of body.querySelectorAll('fieldset[data-question]')) {
908
+ const key = set.dataset.question;
909
+ const typedAnswer = readers.get(set)?.() ?? {};
910
+ if (typedAnswer.error) return typedAnswer;
911
+ const values = [
912
+ typedAnswer.value,
913
+ ...[...set.querySelectorAll('input:checked,select:not([name])')].map(
914
+ (i) => i.value,
915
+ ),
916
+ ].filter(Boolean);
917
+ if (values.length) state.answers[key] = values;
918
+ else delete state.answers[key];
919
+ }
920
+ }
921
+
922
+ /** After the sign-up, an answer the gateway did not carry is the page's to report. */
923
+ function stated() {
924
+ const answers = [];
925
+ for (const [question, values] of Object.entries(state.answers)) {
926
+ const marker = `${question}=${values.join(',')}`;
927
+ if (state.sent.has(marker)) continue;
928
+ state.sent.add(marker);
929
+ answers.push({ question, values });
930
+ }
931
+ const step = state.step.id;
932
+ // After the email step, the gateway relays them to that address's profile and records them.
933
+ if (state.signedUp && answers.length)
934
+ return void api.answers({
935
+ anonymousId: access.anonymousId(),
936
+ sessionId: access.sessionId() ?? undefined,
937
+ slot: decision.slot,
938
+ arm: decision.arm ?? undefined,
939
+ decision_id: decision.decision_id ?? undefined,
940
+ step,
941
+ ...state.signedUp,
942
+ answers,
943
+ context: pageContext(),
944
+ });
945
+ if (access.analytics())
946
+ for (const answer of answers)
947
+ client.preferencesStated({
948
+ ...answer,
949
+ source: 'onsite',
950
+ slot: decision.slot,
951
+ arm: decision.arm ?? null,
952
+ decision_id: decision.decision_id ?? null,
953
+ step,
954
+ });
955
+ }
956
+
957
+ const pageContext = () => ({
958
+ page: { url: access.pageUrl() },
959
+ locale: access.locale() ?? undefined,
960
+ consent: access.consent(),
961
+ });
962
+
963
+ const advance = (explicit) => {
964
+ if (state.receipt && !preview) stated();
965
+ show(nextStep(state.step, state.answers, explicit));
966
+ };
967
+
968
+ async function submit(block) {
969
+ const step = state.step;
970
+ const input = body.querySelector('input[name=email],input[name=phone]');
971
+ if (!input) return advance(block.next);
972
+ const channel = input.name === 'email' ? 'email' : 'sms';
973
+ const country = body.querySelector('select[name=hp_cc]')?.value;
974
+ const value =
975
+ channel === 'email'
976
+ ? isEmail(input.value.trim())
977
+ ? input.value.trim()
978
+ : null
979
+ : e164(input.value, country);
980
+ if (!value)
981
+ return say(
982
+ channel === 'email' ? COPY.email : country ? COPY.phone : COPY.intl,
983
+ input,
984
+ );
985
+ const box = body.querySelector('input[name=hp_consent]');
986
+ if (box && !box.checked) return say(COPY.consent, box);
987
+ say(null);
988
+ busy(true);
989
+ const altcha = await proof();
990
+ state.pow = null;
991
+ const answers = Object.entries(state.answers).map(([question, values]) => ({
992
+ question,
993
+ values,
994
+ }));
995
+ const consentText =
996
+ channel === 'sms' ? form.sms_consent_text : form.consent_text;
997
+ const res = await api.capture({
998
+ anonymousId: access.anonymousId(),
999
+ sessionId: access.sessionId() ?? undefined,
1000
+ slot: decision.slot,
1001
+ arm: decision.arm ?? undefined,
1002
+ decision_id: decision.decision_id ?? undefined,
1003
+ step: step.id,
1004
+ channel,
1005
+ [channel === 'email' ? 'email' : 'phone']: value,
1006
+ ...(channel === 'sms' && state.signedUp),
1007
+ consent_text: consentText
1008
+ ? { id: consentText.id, version: consentText.version }
1009
+ : undefined,
1010
+ ...(answers.length ? { answers } : {}),
1011
+ ...(altcha ? { altcha } : {}),
1012
+ context: pageContext(),
1013
+ });
1014
+ const status = res.body?.status;
1015
+ const taken =
1016
+ (res.status === 202 || res.status === 200) && OK.includes(status);
1017
+ // Closed while it was sent: a sign-up taken still counts, but nothing more is shown.
1018
+ if (state.closed && !taken) return;
1019
+ busy(false);
1020
+ // A contact the gateway refused is the shopper's to correct, in the SDK's own words for it.
1021
+ const field = res.status === 400 ? res.body?.field : null;
1022
+ if (field === 'email' || field === 'phone') return say(COPY[field], input);
1023
+ if (!taken) return say(res.status === 429 ? COPY.busy : COPY.failed, input);
1024
+ // The field is emptied. The receipt remains, for a claim; an email step's address only until
1025
+ // the form closes, for its SMS step.
1026
+ input.value = '';
1027
+ for (const [question, values] of Object.entries(state.answers))
1028
+ state.sent.add(`${question}=${values.join(',')}`);
1029
+ state.receipt =
1030
+ typeof res.body.receipt === 'string' ? res.body.receipt : null;
1031
+ // The address and its receipt let a later SMS step name the shopper, so each destination
1032
+ // updates one profile; they live in this form's state, gone with the form.
1033
+ if (channel === 'email')
1034
+ state.signedUp = { email: value, email_receipt: state.receipt };
1035
+ state.submitted = true;
1036
+ counters.submitted(form.slot);
1037
+ engaged('submit', step.id);
1038
+ // A form with prizes claims its code now, whether or not the shopper goes on to spin, so the
1039
+ // email the sign-up starts can carry it; it tells the page once the claim settles, with the
1040
+ // code or without.
1041
+ state.owed = channel === 'email' && form.prizes.length > 0;
1042
+ if (state.owed) claim();
1043
+ else o.emit?.('submitted', { coupon: null });
1044
+ // A step named for the outcome (`verify`, `already`) is shown for it; otherwise the next one.
1045
+ const special =
1046
+ status !== 'ok' && creative.steps.some((s) => s.id === status);
1047
+ if (!state.closed)
1048
+ show(special ? status : nextStep(step, state.answers, block.next));
1049
+ }
1050
+
1051
+ /** Tells the page, once, that the sign-up was taken: with the claim's code, or with none. */
1052
+ function settled(won) {
1053
+ if (!state.owed) return;
1054
+ state.owed = false;
1055
+ o.emit?.('submitted', {
1056
+ coupon: won ? { code: won.code ?? null, prize: won.prize?.key } : null,
1057
+ });
1058
+ }
1059
+
1060
+ /** The form's claim, made once: a failed one is made again by the next spin. */
1061
+ function claim() {
1062
+ const receipt = state.receipt;
1063
+ state.claiming ??= (async () => {
1064
+ let res;
1065
+ for (const ms of [0, ...CLAIM_RETRY_MS]) {
1066
+ if (ms) await new Promise((r) => setTimeout(r, ms));
1067
+ res = await api.claim({
1068
+ slot: decision.slot,
1069
+ decision_id: decision.decision_id ?? undefined,
1070
+ receipt,
1071
+ });
1072
+ if (res.status !== 503) break;
1073
+ }
1074
+ const won = res.body;
1075
+ const fallback = won?.fallback === true && !won.code;
1076
+ if (res.status !== 200 || !(isObject(won?.prize) || fallback)) {
1077
+ state.claiming = null;
1078
+ settled(null);
1079
+ return null;
1080
+ }
1081
+ state.claim = won;
1082
+ if (typeof won.code === 'string') counters.code?.(won.code);
1083
+ settled(won);
1084
+ if (!state.closed) fillCodes();
1085
+ return won;
1086
+ })();
1087
+ return state.claiming;
1088
+ }
1089
+
1090
+ async function spin(block) {
1091
+ if (!state.receipt) return say(COPY.spin);
1092
+ busy(true);
1093
+ const won = await claim();
1094
+ if (state.closed) return;
1095
+ if (!won) {
1096
+ busy(false);
1097
+ return say(COPY.failed);
1098
+ }
1099
+ const fallback = won.fallback === true && !won.code;
1100
+ // A code the address claimed on another form is that form's prize: the wheel lands only on
1101
+ // its own slice for it, else it stays still. A code another sign-up claimed is never shown
1102
+ // (`held`) and is no win, even of this wheel's prize: the wheel stays still and the code
1103
+ // block says it is already claimed.
1104
+ const index = won.held
1105
+ ? -1
1106
+ : form.prizes.findIndex(
1107
+ (p) => p.key === won.prize?.key && p.label === won.prize?.label,
1108
+ );
1109
+ if (state.wheel && index >= 0 && !fallback) {
1110
+ await state.wheel.spinTo(index);
1111
+ // The prize, said aloud and held a moment where the wheel stopped, before the next step.
1112
+ if (state.closed) return;
1113
+ error.className = 'e won';
1114
+ error.textContent = `${COPY.won} ${won.prize.label}`;
1115
+ await new Promise((r) => setTimeout(r, WON_MS));
1116
+ }
1117
+ if (state.closed) return;
1118
+ busy(false);
1119
+ // No code left: the creative's `fallback` step, else a plain thanks, never a prize "won". A
1120
+ // prize held: its `held` step, else a plain one with the code block.
1121
+ 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);
1131
+ if (to) show(to);
1132
+ else fillCodes();
1133
+ }
1134
+
1135
+ function act(block) {
1136
+ if (block.action === 'close') return close('close');
1137
+ const wrong = collect();
1138
+ // A preview only moves between steps: nothing is checked, sent or claimed.
1139
+ if (preview) return advance(block.next);
1140
+ if (wrong) return say(wrong.error, wrong.input);
1141
+ if (block.action === 'apply') {
1142
+ o.emit?.('apply', {
1143
+ coupon: state.claim?.code ? { code: state.claim.code } : null,
1144
+ });
1145
+ engaged('click');
1146
+ return nextStep(state.step, state.answers, block.next)
1147
+ ? advance(block.next)
1148
+ : close('done');
1149
+ }
1150
+ if (block.action === 'submit') return submit(block);
1151
+ if (block.action === 'spin') return spin(block);
1152
+ return advance(block.next);
1153
+ }
1154
+
1155
+ function submitted(e) {
1156
+ e.preventDefault();
1157
+ if (body.getAttribute('aria-busy') === 'true') return;
1158
+ const first = state.step.blocks.find(
1159
+ (b) => b?.t === 'button' && b.action === 'submit',
1160
+ );
1161
+ act(first ?? { action: 'next' });
1162
+ }
1163
+
1164
+ function teardown() {
1165
+ state.closed = true;
1166
+ for (const t of state.timers.splice(0)) clearInterval(t);
1167
+ document.removeEventListener('visibilitychange', visibility);
1168
+ small?.removeEventListener?.('change', resize);
1169
+ const remove = () => {
1170
+ try {
1171
+ if (shell.open) shell.close();
1172
+ } catch {
1173
+ /* already closed */
1174
+ }
1175
+ host.remove();
1176
+ };
1177
+ if (!dialog || reducedMotion()) return remove();
1178
+ shell.classList.add('out');
1179
+ setTimeout(remove, EXIT_MS);
1180
+ }
1181
+
1182
+ function close(reason) {
1183
+ // A preview stays on screen: closing is the shopper's, not the trader's.
1184
+ if (state.closed || preview) return;
1185
+ if (!state.submitted && reason !== 'done') engaged('dismiss');
1186
+ counters.closed(form.slot);
1187
+ counters.tab?.(form.slot, false);
1188
+ teardown();
1189
+ o.onClose?.(reason);
1190
+ }
1191
+
1192
+ shell.addEventListener('cancel', (e) => {
1193
+ e.preventDefault();
1194
+ close('esc');
1195
+ });
1196
+ shell.addEventListener('keydown', (e) => {
1197
+ if (e.key === 'Escape' && !state.modal && format !== 'inline') close('esc');
1198
+ });
1199
+
1200
+ // Drag a bottom sheet down by its handle to close it.
1201
+ handle.addEventListener('pointerdown', (e) => {
1202
+ if (!state.mobile || state.mode !== 'sheet') return;
1203
+ const from = e.clientY;
1204
+ let dy = 0;
1205
+ const move = (m) => {
1206
+ dy = Math.max(0, m.clientY - from);
1207
+ wrap.style.transform = `translateY(${dy}px)`;
1208
+ };
1209
+ const up = () => {
1210
+ document.removeEventListener('pointermove', move);
1211
+ document.removeEventListener('pointerup', up);
1212
+ wrap.style.transform = '';
1213
+ if (dy > 80) close('close');
1214
+ };
1215
+ document.addEventListener('pointermove', move);
1216
+ document.addEventListener('pointerup', up);
1217
+ });
1218
+
1219
+ show(
1220
+ preview?.step && creative.steps.some((s) => s.id === preview.step)
1221
+ ? preview.step
1222
+ : creative.steps[0].id,
1223
+ );
1224
+ (o.container ?? document.body).append(host);
1225
+ present();
1226
+ return {
1227
+ close,
1228
+ root,
1229
+ host,
1230
+ show,
1231
+ step: () => state.step?.id ?? null,
1232
+ destroy: () => !state.closed && teardown(),
1233
+ };
1234
+ }