@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.
- package/README.md +166 -2
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -0
- package/dist/onsite-render.min.js +182 -0
- package/dist/onsite-wheel.min.js +1 -0
- package/dist/onsite.min.js +1 -0
- package/dist/snippet.min.js +1 -1
- package/package.json +19 -3
- package/src/bootstrap.js +33 -2
- package/src/context.js +20 -7
- package/src/ga4.js +3 -4
- package/src/hp.js +2 -0
- package/src/index.js +100 -5
- package/src/onsite/answers-loader.js +6 -0
- package/src/onsite/answers.js +177 -0
- package/src/onsite/api.js +93 -0
- package/src/onsite/counters.js +144 -0
- package/src/onsite/index.js +525 -0
- package/src/onsite/pow.js +127 -0
- package/src/onsite/preview.js +97 -0
- package/src/onsite/render-loader.js +5 -0
- package/src/onsite/render.js +1507 -0
- package/src/onsite/rich-text.js +73 -0
- package/src/onsite/rules.js +250 -0
- package/src/onsite/styles.js +212 -0
- package/src/onsite/triggers.js +165 -0
- package/src/onsite/wheel-loader.js +5 -0
- package/src/onsite/wheel.js +196 -0
- package/src/onsite-facade.js +123 -0
- package/src/onsite-loader.js +5 -0
- package/src/settings.js +18 -0
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +5 -0
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +15 -1
- package/types/events.d.ts +68 -5
- package/types/hp.d.ts +4 -1
- package/types/index.d.ts +10 -0
- package/types/onsite-preview.d.ts +58 -0
- package/types/onsite-wheel.d.ts +11 -0
- package/types/onsite.d.ts +203 -0
|
@@ -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
|
+
}
|