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