@thg-agentic/hyper-personalisation-web 3.22.2 → 3.24.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,1507 @@
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
+ /** 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'];
35
+ /** Waits before a claim's retries, for an API that answers 503 while it warms up. */
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;
39
+ /** How long a wheel's prize is shown where it stopped before the next step: long enough to read. */
40
+ const WON_MS = 1800;
41
+ const COPY = {
42
+ email: 'Enter a valid email address.',
43
+ phone: 'Enter a valid mobile number.',
44
+ intl: 'Enter a valid mobile number, starting with + and its country code.',
45
+ consent: 'Tick the box to sign up.',
46
+ failed: 'Something went wrong. Please try again.',
47
+ busy: 'Too many tries. Please wait a moment and try again.',
48
+ spin: 'Sign up first to spin.',
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',
55
+ code: 'We’ll email your code to you.',
56
+ claimed: 'This email address has already claimed its code.',
57
+ copied: 'Copied',
58
+ thanks: 'Thanks, you’re signed up',
59
+ unavailable: 'Not available right now.',
60
+ won: 'You won',
61
+ };
62
+
63
+ const isObject = (v) =>
64
+ v !== null && typeof v === 'object' && !Array.isArray(v);
65
+ const oneOf = (list, v, fallback) => (list.includes(v) ? v : fallback);
66
+ const num = (v, lo, hi) =>
67
+ Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : null;
68
+ /** A date or a number: its fields are ./answers.js's. */
69
+ const typed = (q) => q.type === 'date' || q.type === 'number';
70
+ const reducedMotion = () =>
71
+ globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
72
+
73
+ /** A step's layout, normalised: the earlier `layout.image` reads as a split or stack's media. */
74
+ export function layoutOf(step) {
75
+ const l = isObject(step.layout) ? step.layout : {};
76
+ let media = isObject(l.media) ? l.media : null;
77
+ let template = oneOf(TEMPLATES, l.template, 'stack');
78
+ let side = media?.side === 'right' ? 'right' : 'left';
79
+ if (!media && isObject(l.image) && l.image.side !== 'none') {
80
+ media = l.image;
81
+ if (l.image.side !== 'top') {
82
+ template = 'split';
83
+ side = l.image.side === 'right' ? 'right' : 'left';
84
+ }
85
+ }
86
+ if (!media || !assetHref(media.asset)) {
87
+ media = null;
88
+ template = 'stack';
89
+ }
90
+ return {
91
+ template,
92
+ media,
93
+ side,
94
+ scrim: num(l.scrim, 0, 0.9) ?? 0.45,
95
+ padding: oneOf(['none', 'sm', 'md', 'lg'], l.padding, 'md'),
96
+ };
97
+ }
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
+
214
+ let ids = 0;
215
+ const uid = (kind) => `hp-${kind}-${++ids}`;
216
+
217
+ function el(tag, className, text) {
218
+ const node = document.createElement(tag);
219
+ if (className) node.className = className;
220
+ if (text !== undefined) node.append(richText(text));
221
+ return node;
222
+ }
223
+
224
+ const rgb = (hex) => {
225
+ const h =
226
+ hex.length === 4
227
+ ? [...hex.slice(1)].map((c) => c + c).join('')
228
+ : hex.slice(1);
229
+ return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16));
230
+ };
231
+
232
+ /** WCAG 2.x relative luminance of a hex colour. */
233
+ const luminance = (hex) => {
234
+ const [r, g, b] = rgb(hex).map((c) => {
235
+ const s = c / 255;
236
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
237
+ });
238
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
239
+ };
240
+
241
+ /**
242
+ * The text colour on `hex`: white when it reaches `min` (WCAG: 4.5 for text, 3 for large text,
243
+ * which a button's label is), as brands almost always set it; otherwise `#111` or `#fff`,
244
+ * whichever contrasts more.
245
+ */
246
+ export const onColour = (hex, min = 4.5) => {
247
+ const l = luminance(hex) + 0.05;
248
+ const white = 1.05 / l;
249
+ if (white >= min) return '#fff';
250
+ return l / (luminance('#111') + 0.05) > white ? '#111' : '#fff';
251
+ };
252
+
253
+ function theme(host, t) {
254
+ const set = (name, value) => value && host.style.setProperty(name, value);
255
+ const hex = (v) => (HEX.test(v ?? '') ? v : null);
256
+ set('--hp-accent', hex(t.accent));
257
+ set('--hp-bg', hex(t.background));
258
+ set('--hp-text', hex(t.text));
259
+ const radius = num(t.radius, 0, 48);
260
+ if (radius !== null) set('--hp-radius', `${radius}px`);
261
+ // The site's own font (its @font-face rules reach the shadow root), a named system stack, or
262
+ // for anything else, a console font asset included, the default stack.
263
+ set(
264
+ '--hp-font',
265
+ t.font === 'site'
266
+ ? host.ownerDocument.defaultView.getComputedStyle(
267
+ host.parentElement ?? host.ownerDocument.body,
268
+ ).fontFamily
269
+ : Object.hasOwn(FONTS, t.font)
270
+ ? FONTS[t.font]
271
+ : null,
272
+ );
273
+ if (hex(t.accent)) {
274
+ set('--hp-on-accent', onColour(t.accent, 3));
275
+ set('--hp-on-accent-text', onColour(t.accent));
276
+ }
277
+ // On a dark form a field stays light, so it never reads as a second button.
278
+ if (hex(t.background) && luminance(t.background) < 0.18) {
279
+ set('--hp-field', '#fff');
280
+ set('--hp-field-text', '#111');
281
+ }
282
+ // The page behind is dimmed in the brand's own ink, the darker of its text and background,
283
+ // unless the creative names a colour.
284
+ const overlay = isObject(t.overlay) ? t.overlay : {};
285
+ const ink = [hex(t.text), hex(t.background)]
286
+ .filter(Boolean)
287
+ .sort((a, b) => luminance(a) - luminance(b))[0];
288
+ const [r, g, b] = rgb(hex(overlay.colour) ?? ink ?? '#0b0b0f');
289
+ set('--hp-ov', `rgb(${r} ${g} ${b} / ${num(overlay.opacity, 0, 1) ?? 0.6})`);
290
+ }
291
+
292
+ function styles(root) {
293
+ try {
294
+ const sheet = new CSSStyleSheet();
295
+ sheet.replaceSync(CSS);
296
+ root.adoptedStyleSheets = [sheet];
297
+ } catch {
298
+ const style = document.createElement('style');
299
+ style.textContent = CSS;
300
+ root.append(style);
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Render `creative` for `form` and run it until it closes.
306
+ *
307
+ * @param {object} o
308
+ * @param {object} o.form The form from the settings (rules.readForm).
309
+ * @param {object} o.creative readCreative(decision.payload).
310
+ * @param {{ slot: string, arm?: string|null, decision_id?: string|null }} o.decision
311
+ * @param {object} o.access The client's onsite accessor.
312
+ * @param {object} o.client The SDK client, for its events.
313
+ * @param {ReturnType<import('./api.js').createApi>} o.api
314
+ * @param {ReturnType<import('./counters.js').createCounters>} o.counters
315
+ * @param {() => Promise<{ mountWheel: Function }>} o.loadWheel
316
+ * @param {() => Promise<{ answerFields: Function, answerOf: Function }>} o.loadAnswers The fields of
317
+ * a date or number question (./answers.js), loaded once as the form mounts when it asks one.
318
+ * @param {Element|null} [o.container] The page's element, for an inline form.
319
+ * @param {(reason: string) => void} [o.onClose]
320
+ * @param {(name: 'submitted'|'apply', detail: object) => void} [o.emit] To `hp.onsite.on` listeners.
321
+ * @param {string|null} [o.coupon] A code the form starts with: an unused one to remind of.
322
+ * @param {() => number|null} [o.basket] The basket's value, for a progress block.
323
+ * @param {{ device: 'desktop'|'mobile', step?: string }} [o.preview] Render inside `container`,
324
+ * inert but for moving between steps: no network, no events, no counters.
325
+ */
326
+ export function mountForm(o) {
327
+ const { form, creative, decision, access, client, api, counters } = o;
328
+ const preview = o.preview ?? null;
329
+ // A step's first field is what it submits; texts are agreed to in words that name texts. A step
330
+ // with no field shows the email words, or the SMS ones on a form that collects only texts.
331
+ const consentFor = (step) => {
332
+ const field = step.blocks.find((b) => b?.t === 'email' || b?.t === 'phone');
333
+ return field?.t === 'phone' || (!field && !form.consent_text)
334
+ ? form.sms_consent_text
335
+ : form.consent_text;
336
+ };
337
+ const format = formatOf(form, creative);
338
+ const t = creative.theme;
339
+ const position = oneOf(
340
+ POSITIONS[format] ?? [],
341
+ t.position,
342
+ POSITIONS[format]?.[0],
343
+ );
344
+ const host = document.createElement('hp-onsite');
345
+ if (preview) host.setAttribute('preview', '');
346
+ theme(host, t);
347
+ const root = host.attachShadow({ mode: 'open' });
348
+ styles(root);
349
+
350
+ const dialog = format !== 'inline' && !preview;
351
+ const shell = el(dialog ? 'dialog' : format === 'inline' ? 'section' : 'div');
352
+ const scrim = el('div', 'ov');
353
+ const wrap = el('div', 'w');
354
+ const media = el('div', 'm');
355
+ if (preview) media.dataset.hpMedia = '';
356
+ /** The step's blocks: a new form for each step, so the browser reads it as new content, not moved. */
357
+ const blank = () => {
358
+ const f = el('form', 'f');
359
+ f.noValidate = true;
360
+ f.addEventListener('submit', submitted);
361
+ // A message about a field goes once the shopper changes it.
362
+ f.addEventListener('input', () => {
363
+ if (error.textContent && body.getAttribute('aria-busy') !== 'true')
364
+ say(null);
365
+ });
366
+ return f;
367
+ };
368
+ let body = blank();
369
+ const error = el('p', 'e');
370
+ error.id = uid('err');
371
+ error.setAttribute('aria-live', 'assertive');
372
+ const handle = el('div', 'hd');
373
+ handle.setAttribute('aria-hidden', 'true');
374
+ wrap.append(handle, media, body);
375
+ shell.append(scrim, wrap);
376
+ const x = format !== 'inline' && el('button', 'x');
377
+ if (x) {
378
+ x.type = 'button';
379
+ x.addEventListener('click', () => close('close'));
380
+ // After the form in the DOM, so the platform's first focus lands on the form itself.
381
+ wrap.append(x);
382
+ scrim.addEventListener('click', () => close('close'));
383
+ }
384
+ root.append(shell);
385
+
386
+ const state = {
387
+ step: null,
388
+ mode: format,
389
+ size: 'md',
390
+ modal: false,
391
+ mobile: preview?.device === 'mobile',
392
+ answers: {},
393
+ sent: new Set(),
394
+ receipt: null,
395
+ claim: o.coupon
396
+ ? { code: o.coupon }
397
+ : preview
398
+ ? { code: 'WELCOME15' }
399
+ : null,
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,
406
+ owed: false,
407
+ submitted: false,
408
+ pow: null,
409
+ wheel: null,
410
+ timers: [],
411
+ codes: [],
412
+ videos: [],
413
+ closed: false,
414
+ };
415
+
416
+ // The form's language, else the page's: a phone field's country, a birthday's months, money.
417
+ const lang = form.locale || document.documentElement.lang;
418
+ // ./answers.js, asked for once, as the form mounts, when it asks a date or a number; null when it
419
+ // could not load. Each of their fieldsets reads its answer with it.
420
+ const fields =
421
+ form.questions.some(typed) && o.loadAnswers().catch(() => null);
422
+ const readers = new WeakMap();
423
+
424
+ const classes = () => {
425
+ const button = isObject(t.button) ? t.button : {};
426
+ 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' : ''}`;
427
+ };
428
+
429
+ // Small screens by the viewport, a preview by its device.
430
+ const small = preview ? null : globalThis.matchMedia?.('(max-width: 639px)');
431
+ const resize = () => {
432
+ state.mobile = small?.matches === true;
433
+ // A size or a top kept from an earlier step is the old screen's.
434
+ unanchor();
435
+ classes();
436
+ };
437
+ if (small) {
438
+ state.mobile = small.matches === true;
439
+ small.addEventListener?.('change', resize);
440
+ }
441
+
442
+ const engaged = (engagement, step) => {
443
+ if (preview || !decision.decision_id || !access.analytics()) return;
444
+ client.slotEngaged({
445
+ decision_id: decision.decision_id,
446
+ slot: decision.slot,
447
+ arm: decision.arm ?? null,
448
+ engagement,
449
+ step: step ?? state.step?.id ?? null,
450
+ });
451
+ };
452
+
453
+ /** A message under the field it is about (else after the step's blocks); none clears it. */
454
+ const say = (message, input) => {
455
+ error.className = 'e';
456
+ error.textContent = message ?? '';
457
+ for (const node of body.querySelectorAll('[aria-invalid]'))
458
+ node.removeAttribute('aria-invalid');
459
+ const at = input?.closest('label,fieldset');
460
+ if (at && body.contains(at)) at.after(error);
461
+ else body.append(error);
462
+ if (input) {
463
+ input.setAttribute('aria-invalid', 'true');
464
+ input.setAttribute('aria-describedby', error.id);
465
+ input.focus();
466
+ input.scrollIntoView?.({ block: 'nearest' });
467
+ }
468
+ };
469
+
470
+ const busy = (on) => {
471
+ body.setAttribute('aria-busy', String(on));
472
+ for (const b of body.querySelectorAll('button')) b.disabled = on;
473
+ };
474
+
475
+ /** The proof of work, started on the first focus of a contact field so it overlaps typing. */
476
+ const proof = () => {
477
+ if (preview) return Promise.resolve(null);
478
+ state.pow ??= api
479
+ .challenge()
480
+ .then((res) => (res.status === 200 ? solve(res.body) : null))
481
+ .catch(() => null);
482
+ return state.pow;
483
+ };
484
+
485
+ /** Videos play only without reduced motion, and pause while the page is hidden. */
486
+ const visibility = () => {
487
+ for (const v of state.videos) {
488
+ try {
489
+ if (document.hidden || reducedMotion()) v.pause();
490
+ else v.play()?.catch?.(() => {});
491
+ } catch {
492
+ /* a media element without playback */
493
+ }
494
+ }
495
+ };
496
+ document.addEventListener('visibilitychange', visibility);
497
+
498
+ /**
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).
502
+ */
503
+ function visual(m, className, first, part) {
504
+ const src = assetHref(m.asset);
505
+ if (!src) return null;
506
+ const alt = typeof m.alt === 'string' ? m.alt : '';
507
+ let node;
508
+ if (isVideo(m)) {
509
+ node = el('video', className);
510
+ // The properties reflect to the attributes iOS needs before it plays inline.
511
+ node.muted = node.defaultMuted = node.loop = node.playsInline = true;
512
+ const poster = assetHref(m.poster);
513
+ if (poster) node.poster = poster;
514
+ if (alt) node.setAttribute('aria-label', alt);
515
+ else node.setAttribute('aria-hidden', 'true');
516
+ node.autoplay = !reducedMotion() && !preview;
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;
531
+ state.videos.push(node);
532
+ } else {
533
+ node = el('img', className);
534
+ node.alt = alt;
535
+ node.decoding = 'async';
536
+ node.loading = first ? 'eager' : 'lazy';
537
+ if (first) node.fetchPriority = 'high';
538
+ const set = srcset(m, src, num(m.width, 1, 8192));
539
+ // sizes before srcset before src, so the browser fetches one file, the right one.
540
+ if (set) {
541
+ node.sizes = across(state.mode, state.size, part);
542
+ node.srcset = set;
543
+ }
544
+ node.src = src;
545
+ }
546
+ const w = num(m.width, 1, 8192);
547
+ const h = num(m.height, 1, 8192);
548
+ if (w && h) {
549
+ node.width = w;
550
+ node.height = h;
551
+ }
552
+ if (isObject(m.focal))
553
+ node.style.objectPosition = `${num(m.focal.x, 0, 100) ?? 50}% ${num(m.focal.y, 0, 100) ?? 50}%`;
554
+ return node;
555
+ }
556
+
557
+ function field(block, type) {
558
+ const label = el('label', 'l');
559
+ label.append(
560
+ el(
561
+ 'span',
562
+ 'lt',
563
+ block.label ?? (type === 'email' ? 'Email' : 'Mobile number'),
564
+ ),
565
+ );
566
+ const input = el('input', 'i');
567
+ input.type = type;
568
+ input.name = type === 'email' ? 'email' : 'phone';
569
+ input.autocomplete = input.inputMode = type;
570
+ input.required = true;
571
+ if (typeof block.placeholder === 'string')
572
+ input.placeholder = block.placeholder;
573
+ input.setAttribute('aria-describedby', error.id);
574
+ input.addEventListener('focus', proof, { once: true });
575
+ if (type !== 'tel') {
576
+ label.append(input);
577
+ return label;
578
+ }
579
+ // A country code picker, as Klaviyo's forms have: the block's country, else the region of
580
+ // the form's language, else none (a number then starts with +). A typed + or 00 wins over it.
581
+ input.id = `${error.id}-p`;
582
+ label.htmlFor = input.id;
583
+ const pick = select(
584
+ el,
585
+ 'Country code',
586
+ '+',
587
+ Object.entries(CALLING_CODES).map(([c, code]) => [c, `${c} +${code}`]),
588
+ (block.country || /-(\w\w)$/.exec(lang)?.[1] || '').toUpperCase(),
589
+ );
590
+ pick.classList.add('cc');
591
+ pick.name = 'hp_cc';
592
+ const row = el('span', 'ph');
593
+ row.append(pick, input);
594
+ label.append(row);
595
+ return label;
596
+ }
597
+
598
+ /**
599
+ * A question as its type says: a choice as buttons, a list of radio buttons or checkboxes, or a
600
+ * dropdown (the block's `display`; a multiple choice is never a dropdown); a date or a number as
601
+ * its fields (./answers.js).
602
+ */
603
+ function question(block) {
604
+ const q = form.questions.find((x) => x.key === block.question);
605
+ if (!q || (!typed(q) && !Array.isArray(q.options))) return null;
606
+ const set = el('fieldset');
607
+ set.dataset.question = q.key;
608
+ set.append(el('legend', '', q.label ?? ''));
609
+ const chosen = state.answers[q.key] ?? [];
610
+ if (typed(q)) {
611
+ // Without its chunk the question says so, and the form goes on without its answer: every
612
+ // question is optional.
613
+ fields.then((m) => {
614
+ if (!m) return set.append(el('p', 'sm', COPY.unavailable));
615
+ m.answerFields(set, q, el, chosen[0], lang);
616
+ readers.set(set, () => m.answerOf(set, q, undefined, lang));
617
+ });
618
+ return set;
619
+ }
620
+ const many = q.type === 'many';
621
+ const display = oneOf(['list', 'dropdown'], block.display, 'buttons');
622
+ const options = q.options.filter(
623
+ (x) => isObject(x) && typeof x.value === 'string',
624
+ );
625
+ if (display === 'dropdown' && !many) {
626
+ set.append(
627
+ select(
628
+ el,
629
+ q.label ?? q.key,
630
+ '—',
631
+ options.map((x) => [x.value, x.label ?? x.value]),
632
+ chosen[0],
633
+ ),
634
+ );
635
+ return set;
636
+ }
637
+ if (display !== 'buttons') set.className = 'ls';
638
+ for (const x of options) {
639
+ const input = el('input');
640
+ input.value = x.value;
641
+ input.checked = chosen.includes(x.value);
642
+ input.type = many ? 'checkbox' : 'radio';
643
+ input.name = `q_${q.key}`;
644
+ const chip = el('label', display === 'buttons' ? 'ch' : 'c');
645
+ chip.append(input, el('span', '', x.label ?? x.value));
646
+ set.append(chip);
647
+ }
648
+ return set;
649
+ }
650
+
651
+ function fillCodes() {
652
+ for (const box of state.codes) {
653
+ box.replaceChildren();
654
+ if (!state.claim) continue;
655
+ if (!state.claim.code) {
656
+ box.append(el('p', '', state.claim.held ? COPY.claimed : COPY.code));
657
+ continue;
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));
662
+ const code = el('output');
663
+ code.textContent = state.claim.code;
664
+ const copy = el('button', 'btn cp');
665
+ copy.type = 'button';
666
+ copy.textContent = 'Copy code';
667
+ copy.addEventListener('click', () => {
668
+ navigator.clipboard
669
+ ?.writeText(state.claim.code)
670
+ .then(() => {
671
+ copy.textContent = COPY.copied;
672
+ })
673
+ .catch(() => {});
674
+ });
675
+ box.append(code, copy);
676
+ }
677
+ }
678
+
679
+ function countdown(block) {
680
+ // A claim with no code has nothing to hurry for.
681
+ if (state.claim && !state.claim.code) return null;
682
+ const node = el('p', 'tm');
683
+ node.setAttribute('role', 'timer');
684
+ const ends = Date.now() + Math.max(0, Number(block.minutes) || 0) * 60_000;
685
+ const tick = () => {
686
+ const left = Math.max(0, Math.round((ends - Date.now()) / 1000));
687
+ node.textContent = `${String(Math.floor(left / 60)).padStart(2, '0')}:${String(left % 60).padStart(2, '0')}`;
688
+ if (left === 0) clearInterval(id);
689
+ };
690
+ const id = setInterval(tick, 1000);
691
+ state.timers.push(id);
692
+ tick();
693
+ return node;
694
+ }
695
+
696
+ /** A link that opens `href`; a preview's goes nowhere. */
697
+ function link(node, href) {
698
+ node.href = href;
699
+ node.rel = 'noopener';
700
+ node.addEventListener('click', (e) => {
701
+ if (preview) e.preventDefault();
702
+ else engaged('click');
703
+ });
704
+ return node;
705
+ }
706
+
707
+ function button(block) {
708
+ const label = block.label ?? 'Continue';
709
+ if (block.action === 'link') {
710
+ const href = httpsHref(block.href);
711
+ return href
712
+ ? link(el('a', `btn${VARIANTS[block.variant] ?? ''}`, label), href)
713
+ : null;
714
+ }
715
+ const b = el('button', `btn${VARIANTS[block.variant] ?? ''}`, label);
716
+ b.type = block.action === 'submit' ? 'submit' : 'button';
717
+ if (b.type === 'button') b.addEventListener('click', () => act(block));
718
+ else b.dataset.submit = '';
719
+ return b;
720
+ }
721
+
722
+ /** Type size and alignment classes for a heading or text. */
723
+ const typeset = (node, block, size) => {
724
+ node.classList.add(`x-${oneOf(TYPE_SIZES, block.size, size)}`);
725
+ if (ALIGNS.includes(block.align)) node.classList.add(`a-${block.align}`);
726
+ return node;
727
+ };
728
+
729
+ /** An image or video block: its box, fit, radius and tallest height. */
730
+ function picture(block) {
731
+ const node = visual(block, 'bi', state.step === creative.steps[0]);
732
+ if (!node) return null;
733
+ node.style.objectFit = block.fit === 'contain' ? 'contain' : 'cover';
734
+ const radius = num(block.radius, 0, 48);
735
+ if (radius !== null) node.style.borderRadius = `${radius}px`;
736
+ const tallest = num(block.max_height, 40, 800);
737
+ if (tallest !== null) node.style.maxHeight = `${tallest}px`;
738
+ if (!node.width && block.fit !== 'contain')
739
+ node.style.aspectRatio = '16 / 9';
740
+ if (block.t === 'video' || !httpsHref(block.href)) return node;
741
+ const a = link(el('a', 'bl'), httpsHref(block.href));
742
+ a.append(node);
743
+ return a;
744
+ }
745
+
746
+ /** Progress to a basket threshold, from the basket value the SDK keeps; re-read while shown. */
747
+ function progress(block) {
748
+ const threshold = num(block.threshold, 0.01, 1e7);
749
+ if (!threshold) return null;
750
+ const box = el('div', 'pg');
751
+ const label = el('p');
752
+ const track = el('div', 'tr');
753
+ const fill = el('i');
754
+ track.append(fill);
755
+ track.setAttribute('role', 'progressbar');
756
+ track.setAttribute('aria-valuemin', '0');
757
+ track.setAttribute('aria-valuemax', String(threshold));
758
+ box.append(label, track);
759
+ let money = (n) => n.toFixed(2);
760
+ try {
761
+ const format = new Intl.NumberFormat(lang || undefined, {
762
+ style: 'currency',
763
+ currency: block.currency,
764
+ });
765
+ money = (n) => format.format(n);
766
+ } catch {
767
+ /* an unknown currency: the bare amount */
768
+ }
769
+ const paint = () => {
770
+ const value = Math.max(0, Number(o.basket?.()) || 0);
771
+ const left = threshold - value;
772
+ label.textContent =
773
+ left > 0
774
+ ? String(block.text ?? '').replace('{amount}', money(left))
775
+ : (block.done ?? '');
776
+ fill.style.transform = `scaleX(${Math.min(1, value / threshold)})`;
777
+ track.setAttribute('aria-valuenow', String(Math.min(value, threshold)));
778
+ label.id ||= uid('pg');
779
+ track.setAttribute('aria-labelledby', label.id);
780
+ };
781
+ paint();
782
+ state.timers.push(setInterval(paint, 2000));
783
+ return box;
784
+ }
785
+
786
+ const BLOCKS = {
787
+ heading(block) {
788
+ const h = typeset(el('h2', '', block.text), block, 'xl');
789
+ h.id = uid('h');
790
+ h.tabIndex = -1;
791
+ if (!shell.hasAttribute('aria-labelledby')) {
792
+ shell.setAttribute('aria-labelledby', h.id);
793
+ // The heading takes a modal's first focus: read aloud, and no keyboard pops up. A form
794
+ // that does not trap focus never takes it from the page, nor scrolls it.
795
+ if (
796
+ state.step === creative.steps[0] &&
797
+ MODAL.includes(state.mode) &&
798
+ !preview
799
+ )
800
+ h.autofocus = true;
801
+ }
802
+ return h;
803
+ },
804
+ text: (block) => typeset(el('p', '', block.text), block, 'md'),
805
+ email: (block) => field(block, 'email'),
806
+ phone: (block) => field(block, 'tel'),
807
+ consent(block) {
808
+ const text = consentFor(state.step)?.text;
809
+ if (typeof text !== 'string') return null;
810
+ if (block.required !== true) return el('p', 'sm', text);
811
+ const label = el('label', 'c');
812
+ const box = el('input');
813
+ box.type = 'checkbox';
814
+ box.name = 'hp_consent';
815
+ box.required = true;
816
+ box.setAttribute('aria-describedby', error.id);
817
+ label.append(box, el('span', '', text));
818
+ return label;
819
+ },
820
+ question,
821
+ wheel() {
822
+ const box = el('div', 'wh');
823
+ o.loadWheel()
824
+ .then((m) => {
825
+ if (!state.closed) state.wheel = m.mountWheel(box, form.prizes);
826
+ })
827
+ .catch(() => {});
828
+ return box;
829
+ },
830
+ code() {
831
+ const box = el('div', 'k');
832
+ box.setAttribute('aria-live', 'polite');
833
+ state.codes.push(box);
834
+ return box;
835
+ },
836
+ button,
837
+ decline(block) {
838
+ const b = el('button', 'btn no', block.label ?? 'No thanks');
839
+ b.type = 'button';
840
+ b.addEventListener('click', () => close('decline'));
841
+ return b;
842
+ },
843
+ countdown,
844
+ progress,
845
+ image: picture,
846
+ video: picture,
847
+ spacer(block) {
848
+ const node = el('div', 'sp');
849
+ node.style.height = `${SPACERS[block.size] ?? SPACERS.md}px`;
850
+ node.setAttribute('aria-hidden', 'true');
851
+ return node;
852
+ },
853
+ divider: () => el('hr'),
854
+ };
855
+
856
+ /** The step's media: beside, above or behind the blocks, or the whole step as one button. */
857
+ function layout(l, tab) {
858
+ media.replaceChildren();
859
+ const m = l.media;
860
+ const bare = !m || (tab && l.template !== 'image-only');
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' : ''}`;
863
+ wrap.style.setProperty('--sc', String(l.scrim));
864
+ body.className = `f pd-${l.padding}`;
865
+ media.hidden = bare;
866
+ if (media.hidden) return;
867
+ const node = visual(
868
+ m,
869
+ '',
870
+ state.step === creative.steps[0],
871
+ l.template === 'split' ? 0.46 : 1,
872
+ );
873
+ if (!node) return;
874
+ if (node.width)
875
+ wrap.style.setProperty('--ar', `${node.width} / ${node.height}`);
876
+ else wrap.style.removeProperty('--ar');
877
+ if (l.template !== 'image-only') {
878
+ if (node.tagName === 'IMG' && l.template === 'overlay') node.alt = '';
879
+ media.append(node);
880
+ return;
881
+ }
882
+ // One button (or link) the image names: a pure-creative gate or teaser.
883
+ const href = httpsHref(m.href);
884
+ const target = href ? link(el('a', 'io'), href) : el('button', 'io');
885
+ if (!href) {
886
+ target.type = 'button';
887
+ target.addEventListener('click', () => act({ action: 'next' }));
888
+ }
889
+ target.append(node);
890
+ media.append(target);
891
+ }
892
+
893
+ /** A teaser's first step: a tab that opens the rest of the form. */
894
+ function tab(step) {
895
+ const heading = step.blocks.find((b) => b?.t === 'heading');
896
+ const open = step.blocks.find(
897
+ (b) => b?.t === 'button' && b.action === 'next',
898
+ );
899
+ // The heading's words without its marks: a button holds no link.
900
+ const b = el('button', 'tb');
901
+ b.textContent = heading
902
+ ? el('b', '', heading.text).textContent
903
+ : (open?.label ?? 'Open');
904
+ b.type = 'button';
905
+ b.addEventListener('click', () => act(open ?? { action: 'next' }));
906
+ return b;
907
+ }
908
+
909
+ function show(id, skipped = new Set()) {
910
+ const step = creative.steps.find((s) => s.id === id);
911
+ if (!step) return close('done');
912
+ // Without analytics consent an answer is never kept, so the question is not asked.
913
+ if (
914
+ step.kind === 'question' &&
915
+ !preview &&
916
+ !access.analytics() &&
917
+ !skipped.has(step.id)
918
+ )
919
+ return show(nextStep(step, state.answers), skipped.add(step.id));
920
+ const moving = state.step !== null;
921
+ // Focus in the form, or dropped from it to the page (a button disabled while it sent, or
922
+ // taken away with its step), goes to the new step's heading below.
923
+ const refocus =
924
+ moving &&
925
+ (root.activeElement || document.activeElement === document.body);
926
+ // The card the shopper sees now, measured before the step changes; its layout box, so the
927
+ // entrance motion's transform is not in it.
928
+ const was =
929
+ moving && !preview
930
+ ? {
931
+ mode: state.mode,
932
+ w: wrap.offsetWidth,
933
+ h: wrap.offsetHeight,
934
+ top: wrap.offsetTop,
935
+ }
936
+ : null;
937
+ state.step = step;
938
+ state.touched = false;
939
+ for (const t of state.timers.splice(0)) clearInterval(t);
940
+ state.codes = [];
941
+ state.videos = [];
942
+ state.wheel = null;
943
+ const asTab = format === 'teaser' && step === creative.steps[0];
944
+ state.mode = format === 'teaser' && !asTab ? 'popup' : format;
945
+ if (format === 'teaser') counters.tab?.(form.slot, asTab);
946
+ if (x) x.setAttribute('aria-label', asTab ? COPY.closeOffer : 'Close');
947
+ classes();
948
+ shell.removeAttribute('aria-labelledby');
949
+ if (was) {
950
+ const next = blank();
951
+ body.replaceWith(next);
952
+ body = next;
953
+ }
954
+ const l = layoutOf(step);
955
+ layout(l, asTab);
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`);
964
+ wrap.classList.add('lk');
965
+ }
966
+ body.replaceChildren();
967
+ if (asTab && l.template !== 'image-only') body.append(tab(step));
968
+ else
969
+ for (const [index, block] of step.blocks.entries()) {
970
+ const node =
971
+ isObject(block) && Object.hasOwn(BLOCKS, block.t)
972
+ ? BLOCKS[block.t](block)
973
+ : null;
974
+ if (!node) continue;
975
+ // An editor's selection layer finds each block by its place in the step.
976
+ if (preview) node.dataset.hpBlock = String(index);
977
+ body.append(node);
978
+ }
979
+ // A tick box sits directly above the step's submit button wherever the step lists it, so a
980
+ // shopper sees it before pressing, not only after being refused.
981
+ const go = body.querySelector('[data-submit]');
982
+ const tick = body.querySelector('[name=hp_consent]')?.parentNode;
983
+ if (go && tick) go.before(tick);
984
+ body.hidden = body.childElementCount === 0;
985
+ say(null);
986
+ fillCodes();
987
+ if (shell.hasAttribute('aria-labelledby'))
988
+ shell.removeAttribute('aria-label');
989
+ else shell.setAttribute('aria-label', l.media?.alt?.trim?.() || 'Sign up');
990
+ if (settles && was.h > 0) settle(was);
991
+ if (moving) present();
992
+ visibility();
993
+ if (!preview) warmNext(step);
994
+ // A screen reader hears the new step: focus moves to its heading when focus was in the form.
995
+ if (refocus) body.querySelector('h2')?.focus();
996
+ }
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
+
1050
+ /** Open the dialog as the step's mode needs: modal for a popup, non-modal for the rest. */
1051
+ function present() {
1052
+ if (!dialog) return;
1053
+ const modal = MODAL.includes(state.mode);
1054
+ if (shell.open && state.modal === modal) return;
1055
+ try {
1056
+ if (shell.open) shell.close();
1057
+ } catch {
1058
+ /* already closed */
1059
+ }
1060
+ state.modal = modal;
1061
+ shell.setAttribute('aria-modal', String(modal));
1062
+ if (modal && typeof shell.showModal === 'function') shell.showModal();
1063
+ else if (typeof shell.show === 'function') {
1064
+ // A non-modal form never takes the shopper's focus from the page.
1065
+ const was = document.activeElement;
1066
+ shell.show();
1067
+ if (was && was !== document.body) was.focus?.({ preventScroll: true });
1068
+ else host.blur();
1069
+ } else shell.setAttribute('open', '');
1070
+ if (!modal || typeof shell.showModal !== 'function')
1071
+ shell.setAttribute('role', 'dialog');
1072
+ else shell.removeAttribute('role');
1073
+ }
1074
+
1075
+ /**
1076
+ * The answers given on this step, into the form's answers; a date or number that is not one
1077
+ * the question takes is the shopper's to correct: `{ error, input }`.
1078
+ */
1079
+ function collect() {
1080
+ for (const set of body.querySelectorAll('fieldset[data-question]')) {
1081
+ const key = set.dataset.question;
1082
+ const typedAnswer = readers.get(set)?.() ?? {};
1083
+ if (typedAnswer.error) return typedAnswer;
1084
+ const values = [
1085
+ typedAnswer.value,
1086
+ ...[...set.querySelectorAll('input:checked,select:not([name])')].map(
1087
+ (i) => i.value,
1088
+ ),
1089
+ ].filter(Boolean);
1090
+ if (values.length) state.answers[key] = values;
1091
+ else delete state.answers[key];
1092
+ }
1093
+ }
1094
+
1095
+ /** After the sign-up, an answer the gateway did not carry is the page's to report. */
1096
+ function stated() {
1097
+ const answers = [];
1098
+ for (const [question, values] of Object.entries(state.answers)) {
1099
+ const marker = `${question}=${values.join(',')}`;
1100
+ if (state.sent.has(marker)) continue;
1101
+ state.sent.add(marker);
1102
+ answers.push({ question, values });
1103
+ }
1104
+ const step = state.step.id;
1105
+ // After the email step, the gateway relays them to that address's profile and records them.
1106
+ if (state.signedUp && answers.length)
1107
+ return void api.answers({
1108
+ anonymousId: access.anonymousId(),
1109
+ sessionId: access.sessionId() ?? undefined,
1110
+ slot: decision.slot,
1111
+ arm: decision.arm ?? undefined,
1112
+ decision_id: decision.decision_id ?? undefined,
1113
+ step,
1114
+ ...state.signedUp,
1115
+ answers,
1116
+ context: pageContext(),
1117
+ });
1118
+ if (access.analytics())
1119
+ for (const answer of answers)
1120
+ client.preferencesStated({
1121
+ ...answer,
1122
+ source: 'onsite',
1123
+ slot: decision.slot,
1124
+ arm: decision.arm ?? null,
1125
+ decision_id: decision.decision_id ?? null,
1126
+ step,
1127
+ });
1128
+ }
1129
+
1130
+ const pageContext = () => ({
1131
+ page: { url: access.pageUrl() },
1132
+ locale: access.locale() ?? undefined,
1133
+ consent: access.consent(),
1134
+ });
1135
+
1136
+ const advance = (explicit) => {
1137
+ if (state.receipt && !preview) stated();
1138
+ show(nextStep(state.step, state.answers, explicit));
1139
+ };
1140
+
1141
+ async function submit(block) {
1142
+ const step = state.step;
1143
+ const input = body.querySelector('input[name=email],input[name=phone]');
1144
+ if (!input) return advance(block.next);
1145
+ const channel = input.name === 'email' ? 'email' : 'sms';
1146
+ const country = body.querySelector('select[name=hp_cc]')?.value;
1147
+ const value =
1148
+ channel === 'email'
1149
+ ? isEmail(input.value.trim())
1150
+ ? input.value.trim()
1151
+ : null
1152
+ : e164(input.value, country);
1153
+ if (!value)
1154
+ return say(
1155
+ channel === 'email' ? COPY.email : country ? COPY.phone : COPY.intl,
1156
+ input,
1157
+ );
1158
+ const box = body.querySelector('input[name=hp_consent]');
1159
+ if (box && !box.checked) return say(COPY.consent, box);
1160
+ say(null);
1161
+ busy(true);
1162
+ const altcha = await proof();
1163
+ state.pow = null;
1164
+ const answers = Object.entries(state.answers).map(([question, values]) => ({
1165
+ question,
1166
+ values,
1167
+ }));
1168
+ const consentText =
1169
+ channel === 'sms' ? form.sms_consent_text : form.consent_text;
1170
+ const res = await api.capture({
1171
+ anonymousId: access.anonymousId(),
1172
+ sessionId: access.sessionId() ?? undefined,
1173
+ slot: decision.slot,
1174
+ arm: decision.arm ?? undefined,
1175
+ decision_id: decision.decision_id ?? undefined,
1176
+ step: step.id,
1177
+ channel,
1178
+ [channel === 'email' ? 'email' : 'phone']: value,
1179
+ ...(channel === 'sms' && state.signedUp),
1180
+ consent_text: consentText
1181
+ ? { id: consentText.id, version: consentText.version }
1182
+ : undefined,
1183
+ ...(answers.length ? { answers } : {}),
1184
+ ...(altcha ? { altcha } : {}),
1185
+ context: pageContext(),
1186
+ });
1187
+ const status = res.body?.status;
1188
+ const taken =
1189
+ (res.status === 202 || res.status === 200) && OK.includes(status);
1190
+ // Closed while it was sent: a sign-up taken still counts, but nothing more is shown.
1191
+ if (state.closed && !taken) return;
1192
+ if (!taken) busy(false);
1193
+ // A contact the gateway refused is the shopper's to correct, in the SDK's own words for it.
1194
+ const field = res.status === 400 ? res.body?.field : null;
1195
+ if (field === 'email' || field === 'phone') return say(COPY[field], input);
1196
+ if (!taken) return say(res.status === 429 ? COPY.busy : COPY.failed, input);
1197
+ // The field is emptied. The receipt remains, for a claim; an email step's address only until
1198
+ // the form closes, for its SMS step.
1199
+ input.value = '';
1200
+ for (const [question, values] of Object.entries(state.answers))
1201
+ state.sent.add(`${question}=${values.join(',')}`);
1202
+ state.receipt =
1203
+ typeof res.body.receipt === 'string' ? res.body.receipt : null;
1204
+ // The address and its receipt let a later SMS step name the shopper, so each destination
1205
+ // updates one profile; they live in this form's state, gone with the form.
1206
+ if (channel === 'email')
1207
+ state.signedUp = { email: value, email_receipt: state.receipt };
1208
+ state.submitted = true;
1209
+ counters.submitted(form.slot);
1210
+ engaged('submit', step.id);
1211
+ // A form with prizes claims its code now, whether or not the shopper goes on to spin, so the
1212
+ // email the sign-up starts can carry it; it tells the page once the claim settles, with the
1213
+ // code or without.
1214
+ state.owed = channel === 'email' && form.prizes.length > 0;
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'));
1232
+ // A step named for the outcome (`verify`, `already`) is shown for it; otherwise the next one.
1233
+ const special =
1234
+ status !== 'ok' && creative.steps.some((s) => s.id === status);
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;
1272
+ }
1273
+
1274
+ /** Tells the page, once, that the sign-up was taken: with the claim's code, or with none. */
1275
+ function settled(won) {
1276
+ if (!state.owed) return;
1277
+ state.owed = false;
1278
+ o.emit?.('submitted', {
1279
+ coupon: won ? { code: won.code ?? null, prize: won.prize?.key } : null,
1280
+ });
1281
+ }
1282
+
1283
+ /** The form's claim, made once: a failed one is made again by the next spin. */
1284
+ function claim(retries = CLAIM_RETRY_MS) {
1285
+ const receipt = state.receipt;
1286
+ state.claiming ??= (async () => {
1287
+ let res;
1288
+ for (const ms of [0, ...retries]) {
1289
+ if (ms) await new Promise((r) => setTimeout(r, ms));
1290
+ res = await api.claim({
1291
+ slot: decision.slot,
1292
+ decision_id: decision.decision_id ?? undefined,
1293
+ receipt,
1294
+ });
1295
+ if (res.status !== 503) break;
1296
+ }
1297
+ let won = res.body;
1298
+ const fallback = won?.fallback === true && !won.code;
1299
+ if (res.status !== 200 || !(isObject(won?.prize) || fallback)) {
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();
1304
+ settled(null);
1305
+ return null;
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 };
1311
+ state.claim = won;
1312
+ if (typeof won.code === 'string') counters.code?.(won.code);
1313
+ settled(won);
1314
+ if (!state.closed) fillCodes();
1315
+ return won;
1316
+ })();
1317
+ return state.claiming;
1318
+ }
1319
+
1320
+ async function spin(block) {
1321
+ if (!state.receipt) return say(COPY.spin);
1322
+ busy(true);
1323
+ const won = await claim();
1324
+ if (state.closed) return;
1325
+ if (!won) {
1326
+ busy(false);
1327
+ return say(COPY.failed);
1328
+ }
1329
+ const fallback = won.fallback === true && !won.code;
1330
+ // A code the address claimed on another form is that form's prize: the wheel lands only on
1331
+ // its own slice for it, else it stays still. A code another sign-up claimed is never shown
1332
+ // (`held`) and is no win, even of this wheel's prize: the wheel stays still and the code
1333
+ // block says it is already claimed.
1334
+ const index = won.held
1335
+ ? -1
1336
+ : form.prizes.findIndex(
1337
+ (p) => p.key === won.prize?.key && p.label === won.prize?.label,
1338
+ );
1339
+ if (state.wheel && index >= 0 && !fallback) {
1340
+ await state.wheel.spinTo(index);
1341
+ // The prize, said aloud and held a moment where the wheel stopped, before the next step.
1342
+ if (state.closed) return;
1343
+ error.className = 'e won';
1344
+ error.textContent = `${COPY.won} ${won.prize.label}`;
1345
+ await new Promise((r) => setTimeout(r, WON_MS));
1346
+ }
1347
+ if (state.closed) return;
1348
+ busy(false);
1349
+ // No code left: the creative's `fallback` step, else a plain thanks, never a prize "won". A
1350
+ // prize held: its `held` step, else a plain one with the code block.
1351
+ const special = fallback ? 'fallback' : index < 0 ? 'held' : null;
1352
+ const to = special
1353
+ ? ownStep(special)
1354
+ : nextStep(state.step, state.answers, block.next);
1355
+ if (to) show(to);
1356
+ else fillCodes();
1357
+ }
1358
+
1359
+ function act(block) {
1360
+ if (block.action === 'close') return close('close');
1361
+ const wrong = collect();
1362
+ // A preview only moves between steps: nothing is checked, sent or claimed.
1363
+ if (preview) return advance(block.next);
1364
+ if (wrong) return say(wrong.error, wrong.input);
1365
+ if (block.action === 'apply') {
1366
+ o.emit?.('apply', {
1367
+ coupon: state.claim?.code ? { code: state.claim.code } : null,
1368
+ });
1369
+ engaged('click');
1370
+ return nextStep(state.step, state.answers, block.next)
1371
+ ? advance(block.next)
1372
+ : close('done');
1373
+ }
1374
+ if (block.action === 'submit') return submit(block);
1375
+ if (block.action === 'spin') return spin(block);
1376
+ return advance(block.next);
1377
+ }
1378
+
1379
+ function submitted(e) {
1380
+ e.preventDefault();
1381
+ if (body.getAttribute('aria-busy') === 'true') return;
1382
+ const first = state.step.blocks.find(
1383
+ (b) => b?.t === 'button' && b.action === 'submit',
1384
+ );
1385
+ act(first ?? { action: 'next' });
1386
+ }
1387
+
1388
+ function teardown() {
1389
+ state.closed = true;
1390
+ for (const t of state.timers.splice(0)) clearInterval(t);
1391
+ document.removeEventListener('visibilitychange', visibility);
1392
+ small?.removeEventListener?.('change', resize);
1393
+ const remove = () => {
1394
+ try {
1395
+ if (shell.open) shell.close();
1396
+ } catch {
1397
+ /* already closed */
1398
+ }
1399
+ host.remove();
1400
+ };
1401
+ if (!dialog || reducedMotion()) return remove();
1402
+ shell.classList.add('out');
1403
+ setTimeout(remove, EXIT_MS);
1404
+ }
1405
+
1406
+ function close(reason) {
1407
+ // A preview stays on screen: closing is the shopper's, not the trader's.
1408
+ if (state.closed || preview) return;
1409
+ if (!state.submitted && reason !== 'done') engaged('dismiss');
1410
+ counters.closed(form.slot);
1411
+ counters.tab?.(form.slot, false);
1412
+ teardown();
1413
+ o.onClose?.(reason);
1414
+ }
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
+ });
1421
+ shell.addEventListener('cancel', (e) => {
1422
+ e.preventDefault();
1423
+ close('esc');
1424
+ });
1425
+ shell.addEventListener('keydown', (e) => {
1426
+ if (e.key === 'Escape' && !state.modal && format !== 'inline') close('esc');
1427
+ if (e.key === 'Tab' && state.modal) trap(e);
1428
+ });
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
+
1471
+ // Drag a bottom sheet down by its handle to close it.
1472
+ handle.addEventListener('pointerdown', (e) => {
1473
+ if (!state.mobile || state.mode !== 'sheet') return;
1474
+ const from = e.clientY;
1475
+ let dy = 0;
1476
+ const move = (m) => {
1477
+ dy = Math.max(0, m.clientY - from);
1478
+ wrap.style.transform = `translateY(${dy}px)`;
1479
+ };
1480
+ const up = () => {
1481
+ document.removeEventListener('pointermove', move);
1482
+ document.removeEventListener('pointerup', up);
1483
+ wrap.style.transform = '';
1484
+ if (dy > 80) close('close');
1485
+ };
1486
+ document.addEventListener('pointermove', move);
1487
+ document.addEventListener('pointerup', up);
1488
+ });
1489
+
1490
+ show(
1491
+ preview?.step && creative.steps.some((s) => s.id === preview.step)
1492
+ ? preview.step
1493
+ : creative.steps[0].id,
1494
+ );
1495
+ (o.container ?? document.body).append(host);
1496
+ present();
1497
+ return {
1498
+ close,
1499
+ root,
1500
+ host,
1501
+ show,
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,
1505
+ destroy: () => !state.closed && teardown(),
1506
+ };
1507
+ }