@thg-agentic/hyper-personalisation-web 3.28.1 → 3.30.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 +13 -5
- package/dist/hp.min.js +2 -2
- package/dist/onsite-answers.min.js +15 -1
- package/dist/onsite-products.min.js +9 -3
- package/dist/onsite-render.min.js +5 -6
- package/dist/onsite-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +2 -2
- package/src/index.js +2 -0
- package/src/onsite/answers.js +84 -2
- package/src/onsite/api.js +16 -1
- package/src/onsite/countdown.js +35 -0
- package/src/onsite/index.js +238 -102
- package/src/onsite/preview.js +3 -2
- package/src/onsite/products.js +78 -21
- package/src/onsite/render.js +180 -84
- package/src/onsite/rules.js +35 -8
- package/src/onsite/styles.js +0 -1
- package/src/onsite-facade.js +8 -4
- package/src/spec-version.js +1 -1
- package/src/version.js +1 -1
- package/types/events.d.ts +3 -2
- package/types/onsite.d.ts +50 -1
package/src/onsite/products.js
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
import { watchViewability } from '../auto/viewability.js';
|
|
13
13
|
import { httpsHref } from './rich-text.js';
|
|
14
|
+
import { isObject } from './rules.js';
|
|
14
15
|
|
|
16
|
+
// `.btn.bs` is a small button, for a card's: here, as no other block has one, and not `.sm`, which
|
|
17
|
+
// is the stylesheet's small print.
|
|
15
18
|
const CSS = `.pr ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}
|
|
16
19
|
.pr-c ul{grid-auto-flow:column;grid-auto-columns:minmax(132px,42%);overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;padding-bottom:6px;overscroll-behavior-x:contain}
|
|
17
20
|
.pr-c li{scroll-snap-align:start}
|
|
@@ -26,12 +29,16 @@ const CSS = `.pr ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}
|
|
|
26
29
|
.pn{font-size:14px;line-height:1.3;min-height:2.6em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
27
30
|
.pp{font-size:14px;font-weight:700}
|
|
28
31
|
.pp s{font-weight:400;color:var(--mu)}
|
|
29
|
-
.
|
|
32
|
+
.btn.bs{min-height:40px;padding:8px 12px;font-size:14px}
|
|
33
|
+
.pb{margin-top:auto;white-space:nowrap}
|
|
34
|
+
.pr-c{position:relative}
|
|
35
|
+
.ar{position:absolute;z-index:1;top:calc(var(--ph,132px)/2 - 20px);left:-6px;width:40px;height:40px;padding:0 0 3px;border:1px solid var(--ln);border-radius:50%;background:var(--b);color:var(--t);box-shadow:0 2px 8px rgb(0 0 0/.15);font:24px/1 sans-serif;cursor:pointer}
|
|
36
|
+
.ar+.ar{left:auto;right:-6px}
|
|
37
|
+
.ar[aria-disabled=true]{opacity:.35;cursor:default}
|
|
38
|
+
.mob .ar,.pr-f .ar{display:none}
|
|
30
39
|
@media (prefers-reduced-motion:reduce){.pr-c ul{scroll-behavior:auto}}`;
|
|
31
40
|
|
|
32
41
|
const ALIGNS = ['left', 'center', 'right'];
|
|
33
|
-
const isObject = (v) =>
|
|
34
|
-
v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
35
42
|
|
|
36
43
|
/** Money in the form's language; the bare amount for a currency the browser does not know. */
|
|
37
44
|
function moneyIn(lang, currency) {
|
|
@@ -54,7 +61,8 @@ function moneyIn(lang, currency) {
|
|
|
54
61
|
* @param {{ el: Function, lang?: string, root: ShadowRoot, placement: string, preview: boolean,
|
|
55
62
|
* client: object|null, engaged: Function, seen: Set<string> }} o `client` is null when nothing
|
|
56
63
|
* is reported; `seen` holds the form's rails already reported seen, so a step shown again is not.
|
|
57
|
-
* @returns {(() => void)|undefined} Stops watching the cards, for when the step
|
|
64
|
+
* @returns {(() => void)|undefined} Stops watching the cards and the row's size, for when the step
|
|
65
|
+
* changes.
|
|
58
66
|
*/
|
|
59
67
|
export function mountProducts(box, block, o) {
|
|
60
68
|
const { el } = o;
|
|
@@ -149,7 +157,7 @@ export function mountProducts(box, block, o) {
|
|
|
149
157
|
if (show.name === false && !image)
|
|
150
158
|
a.setAttribute('aria-label', name || p.product_id);
|
|
151
159
|
if (typeof block.button === 'string' && block.button)
|
|
152
|
-
a.append(el('span', 'btn pb', block.button));
|
|
160
|
+
a.append(el('span', 'btn bs pb', block.button));
|
|
153
161
|
a.href = httpsHref(p.url);
|
|
154
162
|
a.rel = 'noopener';
|
|
155
163
|
a.addEventListener('click', (e) => {
|
|
@@ -170,21 +178,70 @@ export function mountProducts(box, block, o) {
|
|
|
170
178
|
list.append(item);
|
|
171
179
|
}
|
|
172
180
|
box.append(style, list);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
181
|
+
const ro = grid ? null : arrows(box, list, el);
|
|
182
|
+
const stop =
|
|
183
|
+
client &&
|
|
184
|
+
!seen.has(id) &&
|
|
185
|
+
watchViewability({
|
|
186
|
+
selector: '[data-hp-product]',
|
|
187
|
+
root: o.root,
|
|
188
|
+
onViewable(node) {
|
|
189
|
+
const position = Number(node.dataset.hpPosition);
|
|
190
|
+
if (!viewable.has(position))
|
|
191
|
+
viewable.set(position, {
|
|
192
|
+
position,
|
|
193
|
+
product_id: node.dataset.hpProduct,
|
|
194
|
+
viewable_at: new Date().toISOString(),
|
|
195
|
+
});
|
|
196
|
+
// Deferred a turn, so cards that became viewable together land on one event.
|
|
197
|
+
pending ??= setTimeout(impression, 0);
|
|
198
|
+
return true;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
return () => {
|
|
202
|
+
ro?.disconnect();
|
|
203
|
+
if (stop) stop();
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A carousel's previous and next buttons, for a mouse on a large screen (a phone swipes; Tab walks
|
|
209
|
+
* the cards): each scrolls a page of cards, as smoothly as the shopper allows, and says it can go
|
|
210
|
+
* no further at its end. Hidden when every card fits. They sit level with the middle of the
|
|
211
|
+
* pictures, whose height (`--ph`, a square as wide as a card) is measured with the row. They come
|
|
212
|
+
* before the row, so Tab reaches them before the cards they page. Returns what watches the row's
|
|
213
|
+
* size.
|
|
214
|
+
*/
|
|
215
|
+
function arrows(box, list, el) {
|
|
216
|
+
const buttons = ['Previous', 'Next'].map((label, i) => {
|
|
217
|
+
const b = el('button', 'ar', i ? '›' : '‹');
|
|
218
|
+
b.type = 'button';
|
|
219
|
+
b.setAttribute('aria-label', `${label} products`);
|
|
220
|
+
b.addEventListener('click', () =>
|
|
221
|
+
list.scrollBy({ left: (i ? 1 : -1) * list.clientWidth }),
|
|
222
|
+
);
|
|
223
|
+
list.before(b);
|
|
224
|
+
return b;
|
|
189
225
|
});
|
|
226
|
+
// Every read before any write, and a write only when something changed, so scrolling never
|
|
227
|
+
// makes the browser lay the row out again.
|
|
228
|
+
let painted = '';
|
|
229
|
+
const paint = () => {
|
|
230
|
+
const end = list.scrollWidth - list.clientWidth;
|
|
231
|
+
const at = list.scrollLeft;
|
|
232
|
+
const picture = list.firstElementChild?.offsetWidth ?? 0;
|
|
233
|
+
const now = [end < 2, at < 2, at > end - 2, picture];
|
|
234
|
+
if (`${now}` === painted) return;
|
|
235
|
+
painted = `${now}`;
|
|
236
|
+
box.classList.toggle('pr-f', now[0]);
|
|
237
|
+
box.style.setProperty('--ph', `${picture}px`);
|
|
238
|
+
buttons[0].setAttribute('aria-disabled', String(now[1]));
|
|
239
|
+
buttons[1].setAttribute('aria-disabled', String(now[2]));
|
|
240
|
+
};
|
|
241
|
+
list.addEventListener('scroll', paint, { passive: true });
|
|
242
|
+
paint();
|
|
243
|
+
// Told the row's size again once it is laid out, and as it changes.
|
|
244
|
+
const ro = globalThis.ResizeObserver && new ResizeObserver(paint);
|
|
245
|
+
ro?.observe(list);
|
|
246
|
+
return ro;
|
|
190
247
|
}
|
package/src/onsite/render.js
CHANGED
|
@@ -11,7 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
import { solve } from './pow.js';
|
|
13
13
|
import { assetHref, httpsHref, richText } from './rich-text.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
CALLING_CODES,
|
|
16
|
+
cardColumns,
|
|
17
|
+
e164,
|
|
18
|
+
isEmail,
|
|
19
|
+
isObject,
|
|
20
|
+
nextStep,
|
|
21
|
+
select,
|
|
22
|
+
} from './rules.js';
|
|
15
23
|
import { CSS, FONTS } from './styles.js';
|
|
16
24
|
|
|
17
25
|
const HEX = /^#(?:[0-9a-f]{3}){1,2}$/i;
|
|
@@ -62,8 +70,6 @@ const COPY = {
|
|
|
62
70
|
won: 'You won',
|
|
63
71
|
};
|
|
64
72
|
|
|
65
|
-
const isObject = (v) =>
|
|
66
|
-
v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
67
73
|
const oneOf = (list, v, fallback) => (list.includes(v) ? v : fallback);
|
|
68
74
|
const num = (v, lo, hi) =>
|
|
69
75
|
Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : null;
|
|
@@ -104,20 +110,27 @@ const sizeOf = (t, l) =>
|
|
|
104
110
|
(l.template === 'split' || l.template === 'overlay' ? 'lg' : 'md');
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
|
-
* How wide an image shows, for the browser to pick from its srcset: the whole screen on
|
|
108
|
-
* one or a full-size form, else `part` of the card, a sheet or a teaser's tab.
|
|
113
|
+
* How wide an image shows, for the browser to pick from its srcset: `small` (the whole screen) on
|
|
114
|
+
* a small one or a full-size form, else `part` of the card, a sheet or a teaser's tab.
|
|
109
115
|
*/
|
|
110
|
-
const across = (mode, size, part = 1) => {
|
|
111
|
-
if (mode === 'fullscreen' || size === 'full') return
|
|
116
|
+
const across = (mode, size, part = 1, small = '100vw') => {
|
|
117
|
+
if (mode === 'fullscreen' || size === 'full') return small;
|
|
112
118
|
const px =
|
|
113
119
|
mode === 'teaser'
|
|
114
120
|
? 220
|
|
115
121
|
: mode === 'sheet'
|
|
116
122
|
? 400
|
|
117
123
|
: (CARD_PX[size] ?? CARD_PX.md);
|
|
118
|
-
return `(max-width: 639px)
|
|
124
|
+
return `(max-width: 639px) ${small}, ${Math.round(px * part)}px`;
|
|
119
125
|
};
|
|
120
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A picture card of question `q`'s share of the form: half a narrow form, two a row, and of a large
|
|
129
|
+
* one one of its columns (`cardColumns`, as answers.js lays them out). On a phone, half the screen
|
|
130
|
+
* (`across`'s `small`, '50vw').
|
|
131
|
+
*/
|
|
132
|
+
const cardPart = (size, q) => (size === 'lg' ? 1 / cardColumns(q) : 0.5);
|
|
133
|
+
|
|
121
134
|
/**
|
|
122
135
|
* The smaller copies the upload made of a WebP image (`widths`, any of them, each at `-<w>w.webp`)
|
|
123
136
|
* and the image itself at its width, as a srcset; null when there are none to offer.
|
|
@@ -187,21 +200,32 @@ export function warm(form, creative, id, seen = new Set()) {
|
|
|
187
200
|
const size = sizeOf(creative.theme, l);
|
|
188
201
|
// A teaser's tab shows only its button, or its picture when that is all it is: the rest of the
|
|
189
202
|
// form opens as a popup, warmed with the popup's sizes once the tab is up (warmNext).
|
|
203
|
+
// Each entry: a picture, and its part of the card and of a phone, as the renderer sizes it;
|
|
204
|
+
// picture cards, one picture an answer, as many as the question has.
|
|
190
205
|
const wanted = tab
|
|
191
206
|
? []
|
|
192
|
-
: step.blocks
|
|
193
|
-
|
|
194
|
-
|
|
207
|
+
: step.blocks.flatMap((b) => {
|
|
208
|
+
if (b?.t === 'image' || b?.t === 'video') return [[b]];
|
|
209
|
+
// Picture cards are a single choice's: any other question shows as it would without them.
|
|
210
|
+
const q =
|
|
211
|
+
b?.display === 'cards' &&
|
|
212
|
+
form.questions.find((x) => x.key === b.question && x.type === 'one');
|
|
213
|
+
return q
|
|
214
|
+
? Object.values(b.pictures ?? {})
|
|
215
|
+
.filter(isObject)
|
|
216
|
+
.map((p) => [p, cardPart(size, q), '50vw'])
|
|
217
|
+
: [];
|
|
218
|
+
});
|
|
195
219
|
if (l.media && !(tab && l.template !== 'image-only'))
|
|
196
220
|
wanted.push([l.media, l.template === 'split' ? 0.46 : 1]);
|
|
197
221
|
return Promise.all(
|
|
198
|
-
wanted.map(([m, part]) => {
|
|
222
|
+
wanted.map(([m, part, small]) => {
|
|
199
223
|
const src = assetHref(isVideo(m) ? m.poster : m.asset);
|
|
200
224
|
if (!src) return null;
|
|
201
225
|
const img = document.createElement('img');
|
|
202
226
|
const set = !isVideo(m) && srcset(m, src, num(m.width, 1, 8192));
|
|
203
227
|
if (set) {
|
|
204
|
-
img.sizes = across(mode, size, part);
|
|
228
|
+
img.sizes = across(mode, size, part, small);
|
|
205
229
|
img.srcset = set;
|
|
206
230
|
}
|
|
207
231
|
const key = `${img.sizes} ${set || src}`;
|
|
@@ -320,8 +344,9 @@ function styles(root) {
|
|
|
320
344
|
* @param {() => Promise<{ mountWheel: Function }>} o.loadWheel
|
|
321
345
|
* @param {() => Promise<{ mountProducts: Function }>} [o.loadProducts] A products block's cards
|
|
322
346
|
* (./products.js), loaded when a step shows one.
|
|
323
|
-
* @param {() => Promise<{ answerFields: Function, answerOf: Function }>} o.loadAnswers
|
|
324
|
-
*
|
|
347
|
+
* @param {() => Promise<{ answerFields: Function, answerOf: Function, answerCards: Function, countdown: Function }>} o.loadAnswers
|
|
348
|
+
* A date or number question's fields, picture cards and a countdown's ticking (./answers.js),
|
|
349
|
+
* loaded once as the form mounts when it shows any of them.
|
|
325
350
|
* @param {Element|null} [o.container] The page's element, for an inline form.
|
|
326
351
|
* @param {(reason: string) => void} [o.onClose]
|
|
327
352
|
* @param {(name: 'submitted'|'apply', detail: object) => void} [o.emit] To `hp.onsite.on` listeners.
|
|
@@ -397,7 +422,11 @@ export function mountForm(o) {
|
|
|
397
422
|
modal: false,
|
|
398
423
|
mobile: preview?.device === 'mobile',
|
|
399
424
|
answers: {},
|
|
425
|
+
// The step each answer was given on, by question.
|
|
426
|
+
answeredOn: {},
|
|
400
427
|
sent: new Set(),
|
|
428
|
+
// Whether this view's click is counted: a view counts one, whatever was clicked.
|
|
429
|
+
clicked: false,
|
|
401
430
|
// The products blocks' rails reported seen, by id: a step shown again does not report it twice.
|
|
402
431
|
seen: new Set(),
|
|
403
432
|
receipt: null,
|
|
@@ -427,10 +456,15 @@ export function mountForm(o) {
|
|
|
427
456
|
|
|
428
457
|
// The form's language, else the page's: a phone field's country, a birthday's months, money.
|
|
429
458
|
const lang = form.locale || document.documentElement.lang;
|
|
430
|
-
// ./answers.js,
|
|
431
|
-
//
|
|
459
|
+
// ./answers.js, fetched once as the form mounts when it asks a date or a number, or any step
|
|
460
|
+
// shows picture cards or a countdown, so none waits for it once its step shows; null when it
|
|
461
|
+
// could not load. Each date or number reads its answer with it.
|
|
432
462
|
const fields =
|
|
433
|
-
form.questions.some(typed)
|
|
463
|
+
(form.questions.some(typed) ||
|
|
464
|
+
creative.steps.some((s) =>
|
|
465
|
+
s.blocks.some((b) => b?.t === 'countdown' || b?.display === 'cards'),
|
|
466
|
+
)) &&
|
|
467
|
+
o.loadAnswers().catch(() => null);
|
|
434
468
|
const readers = new WeakMap();
|
|
435
469
|
|
|
436
470
|
const classes = () => {
|
|
@@ -451,8 +485,17 @@ export function mountForm(o) {
|
|
|
451
485
|
small.addEventListener?.('change', resize);
|
|
452
486
|
}
|
|
453
487
|
|
|
488
|
+
/** A form that collects nothing: a message, products, or a quiz. */
|
|
489
|
+
const asksNothing = !form.channels?.length;
|
|
490
|
+
|
|
454
491
|
const engaged = (engagement, step, item = {}) => {
|
|
455
492
|
if (preview || !decision.decision_id || !access.analytics()) return;
|
|
493
|
+
// A view counts one click; a products card's always says which product was clicked.
|
|
494
|
+
if (engagement === 'click') {
|
|
495
|
+
const counted = state.clicked;
|
|
496
|
+
state.clicked = true;
|
|
497
|
+
if (counted && !item.products) return;
|
|
498
|
+
}
|
|
456
499
|
client.slotEngaged({
|
|
457
500
|
decision_id: decision.decision_id,
|
|
458
501
|
slot: decision.slot,
|
|
@@ -511,9 +554,10 @@ export function mountForm(o) {
|
|
|
511
554
|
/**
|
|
512
555
|
* An image or video for an asset, its box reserved by its size and cropped to its focal point.
|
|
513
556
|
* `first`: on the form's first step, fetched at once and ahead of the page's other images; a
|
|
514
|
-
* later step's waits until it shows (and was warmed while the step before it showed).
|
|
557
|
+
* later step's waits until it shows (and was warmed while the step before it showed). It
|
|
558
|
+
* shows across `part` of the card, and `small` of a phone (`across`).
|
|
515
559
|
*/
|
|
516
|
-
function visual(m, className, first, part) {
|
|
560
|
+
function visual(m, className, first, part, small) {
|
|
517
561
|
const src = assetHref(m.asset);
|
|
518
562
|
if (!src) return null;
|
|
519
563
|
const alt = typeof m.alt === 'string' ? m.alt : '';
|
|
@@ -551,7 +595,7 @@ export function mountForm(o) {
|
|
|
551
595
|
const set = srcset(m, src, num(m.width, 1, 8192));
|
|
552
596
|
// sizes before srcset before src, so the browser fetches one file, the right one.
|
|
553
597
|
if (set) {
|
|
554
|
-
node.sizes = across(state.mode, state.size, part);
|
|
598
|
+
node.sizes = across(state.mode, state.size, part, small);
|
|
555
599
|
node.srcset = set;
|
|
556
600
|
}
|
|
557
601
|
node.src = src;
|
|
@@ -610,8 +654,8 @@ export function mountForm(o) {
|
|
|
610
654
|
|
|
611
655
|
/**
|
|
612
656
|
* A question as its type says: a choice as buttons, a list of radio buttons or checkboxes, or a
|
|
613
|
-
* dropdown (the block's `display`; a multiple choice is never a dropdown)
|
|
614
|
-
* its fields (./answers.js).
|
|
657
|
+
* dropdown (the block's `display`; a multiple choice is never a dropdown), or a single choice as
|
|
658
|
+
* picture cards (./answers.js); a date or a number as its fields (./answers.js).
|
|
615
659
|
*/
|
|
616
660
|
function question(block) {
|
|
617
661
|
const q = form.questions.find((x) => x.key === block.question);
|
|
@@ -620,11 +664,23 @@ export function mountForm(o) {
|
|
|
620
664
|
set.dataset.question = q.key;
|
|
621
665
|
set.append(el('legend', '', q.label ?? ''));
|
|
622
666
|
const chosen = state.answers[q.key] ?? [];
|
|
623
|
-
|
|
667
|
+
// Picture cards are a single choice's: any other question shows as it would without them.
|
|
668
|
+
const cards = block.display === 'cards' && q.type === 'one';
|
|
669
|
+
if (typed(q) || cards) {
|
|
624
670
|
// Without its chunk the question says so, and the form goes on without its answer: every
|
|
625
671
|
// question is optional.
|
|
626
672
|
fields.then((m) => {
|
|
627
673
|
if (!m) return set.append(el('p', 'sm', COPY.unavailable));
|
|
674
|
+
if (cards)
|
|
675
|
+
return m.answerCards(
|
|
676
|
+
set,
|
|
677
|
+
q,
|
|
678
|
+
block,
|
|
679
|
+
el,
|
|
680
|
+
chosen,
|
|
681
|
+
(p) => visual(p, 'qi', false, cardPart(state.size, q), '50vw'),
|
|
682
|
+
root,
|
|
683
|
+
);
|
|
628
684
|
m.answerFields(set, q, el, chosen[0], lang);
|
|
629
685
|
readers.set(set, () => m.answerOf(set, q, undefined, lang));
|
|
630
686
|
});
|
|
@@ -649,12 +705,14 @@ export function mountForm(o) {
|
|
|
649
705
|
}
|
|
650
706
|
if (display !== 'buttons') set.className = 'ls';
|
|
651
707
|
for (const x of options) {
|
|
708
|
+
const chip = el('label', display === 'buttons' ? 'ch' : 'c');
|
|
709
|
+
// The same input as rules.js choiceInput builds, written out: importing it costs the
|
|
710
|
+
// renderer bytes it does not have.
|
|
652
711
|
const input = el('input');
|
|
653
|
-
input.value = x.value;
|
|
654
|
-
input.checked = chosen.includes(x.value);
|
|
655
712
|
input.type = many ? 'checkbox' : 'radio';
|
|
656
713
|
input.name = `q_${q.key}`;
|
|
657
|
-
|
|
714
|
+
input.value = x.value;
|
|
715
|
+
input.checked = chosen.includes(x.value);
|
|
658
716
|
chip.append(input, el('span', '', x.label ?? x.value));
|
|
659
717
|
set.append(chip);
|
|
660
718
|
}
|
|
@@ -699,21 +757,23 @@ export function mountForm(o) {
|
|
|
699
757
|
}
|
|
700
758
|
}
|
|
701
759
|
|
|
760
|
+
/**
|
|
761
|
+
* A countdown's place: the answers chunk draws its label and timer into it and ticks it
|
|
762
|
+
* (./countdown.js), showing it, and hides it again once a deadline passes. Hidden until then, so
|
|
763
|
+
* a chunk that never comes leaves nothing behind, not an empty timer.
|
|
764
|
+
*/
|
|
702
765
|
function countdown(block) {
|
|
703
766
|
// A claim with no code has nothing to hurry for.
|
|
704
767
|
if (state.claim && !state.claim.code) return null;
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
state.timers.push(id);
|
|
715
|
-
tick();
|
|
716
|
-
return node;
|
|
768
|
+
const box = el('div', 'cd');
|
|
769
|
+
box.hidden = true;
|
|
770
|
+
fields.then(
|
|
771
|
+
(m) =>
|
|
772
|
+
m &&
|
|
773
|
+
box.isConnected &&
|
|
774
|
+
state.timers.push(m.countdown(box, block, preview, el)),
|
|
775
|
+
);
|
|
776
|
+
return box;
|
|
717
777
|
}
|
|
718
778
|
|
|
719
779
|
/** A link that opens `href`; a preview's goes nowhere. */
|
|
@@ -721,8 +781,9 @@ export function mountForm(o) {
|
|
|
721
781
|
node.href = href;
|
|
722
782
|
node.rel = 'noopener';
|
|
723
783
|
node.addEventListener('click', (e) => {
|
|
724
|
-
if (preview) e.preventDefault();
|
|
725
|
-
|
|
784
|
+
if (preview) return e.preventDefault();
|
|
785
|
+
engaged('click');
|
|
786
|
+
leaving();
|
|
726
787
|
});
|
|
727
788
|
return node;
|
|
728
789
|
}
|
|
@@ -1137,54 +1198,76 @@ export function mountForm(o) {
|
|
|
1137
1198
|
(i) => i.value,
|
|
1138
1199
|
),
|
|
1139
1200
|
].filter(Boolean);
|
|
1140
|
-
if (values.length)
|
|
1141
|
-
|
|
1201
|
+
if (values.length) {
|
|
1202
|
+
state.answers[key] = values;
|
|
1203
|
+
state.answeredOn[key] = state.step.id;
|
|
1204
|
+
} else delete state.answers[key];
|
|
1142
1205
|
}
|
|
1143
1206
|
}
|
|
1144
1207
|
|
|
1145
|
-
/**
|
|
1208
|
+
/**
|
|
1209
|
+
* An answer the gateway did not carry is the page's to report: after the sign-up, or, on a form
|
|
1210
|
+
* that collects nothing (a quiz), as each step's answers are given.
|
|
1211
|
+
*/
|
|
1146
1212
|
function stated() {
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
answers.push({ question, values });
|
|
1153
|
-
}
|
|
1154
|
-
const step = state.step.id;
|
|
1213
|
+
const fresh = Object.entries(state.answers)
|
|
1214
|
+
.map(([question, values]) => ({ question, values }))
|
|
1215
|
+
.filter((a) => !state.sent.has(marker(a)));
|
|
1216
|
+
// Each answer goes with the step it was given on (collect records it), maybe an earlier one.
|
|
1217
|
+
const stepOf = ({ question }) => state.answeredOn[question];
|
|
1155
1218
|
// After the email step, the gateway relays them to that address's profile and records them.
|
|
1156
|
-
if (state.signedUp
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
step,
|
|
1164
|
-
...state.signedUp,
|
|
1165
|
-
answers,
|
|
1166
|
-
context: pageContext(),
|
|
1167
|
-
});
|
|
1168
|
-
if (access.analytics())
|
|
1169
|
-
for (const answer of answers)
|
|
1170
|
-
client.preferencesStated({
|
|
1171
|
-
...answer,
|
|
1172
|
-
source: 'onsite',
|
|
1219
|
+
if (state.signedUp) {
|
|
1220
|
+
for (const step of new Set(fresh.map(stepOf))) {
|
|
1221
|
+
const answers = fresh.filter((a) => stepOf(a) === step);
|
|
1222
|
+
for (const answer of answers) state.sent.add(marker(answer));
|
|
1223
|
+
api.answers({
|
|
1224
|
+
anonymousId: access.anonymousId(),
|
|
1225
|
+
sessionId: access.sessionId() ?? undefined,
|
|
1173
1226
|
slot: decision.slot,
|
|
1174
|
-
arm: decision.arm ??
|
|
1175
|
-
decision_id: decision.decision_id ??
|
|
1227
|
+
arm: decision.arm ?? undefined,
|
|
1228
|
+
decision_id: decision.decision_id ?? undefined,
|
|
1176
1229
|
step,
|
|
1230
|
+
...state.signedUp,
|
|
1231
|
+
answers,
|
|
1232
|
+
context: pageContext(),
|
|
1177
1233
|
});
|
|
1234
|
+
}
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
// Kept back without analytics consent, so a later grant still reports it.
|
|
1238
|
+
if (!access.analytics()) return;
|
|
1239
|
+
for (const answer of fresh) {
|
|
1240
|
+
state.sent.add(marker(answer));
|
|
1241
|
+
client.preferencesStated({
|
|
1242
|
+
...answer,
|
|
1243
|
+
source: 'onsite',
|
|
1244
|
+
slot: decision.slot,
|
|
1245
|
+
arm: decision.arm ?? null,
|
|
1246
|
+
decision_id: decision.decision_id ?? null,
|
|
1247
|
+
step: stepOf(answer),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1178
1250
|
}
|
|
1179
1251
|
|
|
1252
|
+
const marker = ({ question, values }) => `${question}=${values.join(',')}`;
|
|
1253
|
+
|
|
1254
|
+
/** A quiz left by a link or closed keeps the answers given so far, as on moving on. */
|
|
1255
|
+
const leaving = () => {
|
|
1256
|
+
if (!asksNothing) return;
|
|
1257
|
+
collect();
|
|
1258
|
+
stated();
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1180
1261
|
const pageContext = () => ({
|
|
1181
1262
|
page: { url: access.pageUrl() },
|
|
1182
1263
|
locale: access.locale() ?? undefined,
|
|
1183
1264
|
consent: access.consent(),
|
|
1184
1265
|
});
|
|
1185
1266
|
|
|
1267
|
+
// Answers are reported as the form moves on: after a receipt, or on a form that collects nothing.
|
|
1268
|
+
// A sign-up form's answers before the sign-up travel with it.
|
|
1186
1269
|
const advance = (explicit) => {
|
|
1187
|
-
if (state.receipt
|
|
1270
|
+
if (!preview && (state.receipt || asksNothing)) stated();
|
|
1188
1271
|
show(nextStep(state.step, state.answers, explicit));
|
|
1189
1272
|
};
|
|
1190
1273
|
|
|
@@ -1428,14 +1511,19 @@ export function mountForm(o) {
|
|
|
1428
1511
|
// A preview only moves between steps: nothing is checked, sent or claimed.
|
|
1429
1512
|
if (preview) return advance(block.next);
|
|
1430
1513
|
if (wrong) return say(wrong.error, wrong.input);
|
|
1514
|
+
// A quiz learns from clicks: moving on with an answer is a click.
|
|
1515
|
+
if (
|
|
1516
|
+
asksNothing &&
|
|
1517
|
+
state.step.blocks.some((b) => state.answers[b?.question])
|
|
1518
|
+
)
|
|
1519
|
+
engaged('click');
|
|
1431
1520
|
if (block.action === 'apply') {
|
|
1432
1521
|
o.emit?.('apply', {
|
|
1433
1522
|
coupon: state.claim?.code ? { code: state.claim.code } : null,
|
|
1434
1523
|
});
|
|
1435
1524
|
engaged('click');
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
: close('done');
|
|
1525
|
+
// With no step after it, the form closes, done.
|
|
1526
|
+
return advance(block.next);
|
|
1439
1527
|
}
|
|
1440
1528
|
if (block.action === 'submit') return submit(block);
|
|
1441
1529
|
if (block.action === 'spin') return spin(block);
|
|
@@ -1451,7 +1539,8 @@ export function mountForm(o) {
|
|
|
1451
1539
|
act(first ?? { action: 'next' });
|
|
1452
1540
|
}
|
|
1453
1541
|
|
|
1454
|
-
|
|
1542
|
+
/** Off the screen, with its listeners; `now` without the exit animation. */
|
|
1543
|
+
function teardown(now = false) {
|
|
1455
1544
|
state.closed = true;
|
|
1456
1545
|
for (const t of state.timers.splice(0)) stopTimer(t);
|
|
1457
1546
|
document.removeEventListener('visibilitychange', visibility);
|
|
@@ -1464,7 +1553,7 @@ export function mountForm(o) {
|
|
|
1464
1553
|
}
|
|
1465
1554
|
host.remove();
|
|
1466
1555
|
};
|
|
1467
|
-
if (!dialog || reducedMotion()) return remove();
|
|
1556
|
+
if (now || !dialog || reducedMotion()) return remove();
|
|
1468
1557
|
shell.classList.add('out');
|
|
1469
1558
|
setTimeout(remove, EXIT_MS);
|
|
1470
1559
|
}
|
|
@@ -1472,6 +1561,7 @@ export function mountForm(o) {
|
|
|
1472
1561
|
function close(reason) {
|
|
1473
1562
|
// A preview stays on screen: closing is the shopper's, not the trader's.
|
|
1474
1563
|
if (state.closed || preview) return;
|
|
1564
|
+
leaving();
|
|
1475
1565
|
if (!state.submitted && reason !== 'done') engaged('dismiss');
|
|
1476
1566
|
counters.closed(form.slot);
|
|
1477
1567
|
counters.tab?.(form.slot, false);
|
|
@@ -1553,13 +1643,19 @@ export function mountForm(o) {
|
|
|
1553
1643
|
document.addEventListener('pointerup', up);
|
|
1554
1644
|
});
|
|
1555
1645
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1646
|
+
// A form that fails to open leaves nothing behind: no host, no listener on the document.
|
|
1647
|
+
try {
|
|
1648
|
+
show(
|
|
1649
|
+
preview?.step && creative.steps.some((s) => s.id === preview.step)
|
|
1650
|
+
? preview.step
|
|
1651
|
+
: creative.steps[0].id,
|
|
1652
|
+
);
|
|
1653
|
+
(o.container ?? document.body).append(host);
|
|
1654
|
+
present();
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
teardown(true);
|
|
1657
|
+
throw err;
|
|
1658
|
+
}
|
|
1563
1659
|
return {
|
|
1564
1660
|
close,
|
|
1565
1661
|
root,
|
package/src/onsite/rules.js
CHANGED
|
@@ -35,7 +35,7 @@ export const OVERLAY = (format) => format !== 'inline';
|
|
|
35
35
|
export const INTERRUPTS = (format) =>
|
|
36
36
|
format === 'popup' || format === 'sheet' || format === 'fullscreen';
|
|
37
37
|
|
|
38
|
-
const isObject = (v) =>
|
|
38
|
+
export const isObject = (v) =>
|
|
39
39
|
v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
40
40
|
const strings = (v) =>
|
|
41
41
|
Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
|
|
@@ -192,17 +192,20 @@ export function blockers(form, env) {
|
|
|
192
192
|
export const byPriority = (a, b) => b.priority - a.priority;
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
-
* The step after `step`: the button's own `next`, else the first `answers` rule
|
|
196
|
-
*
|
|
195
|
+
* The step after `step`: the button's own `next`, else the first `answers` rule (`"question=value"`)
|
|
196
|
+
* an answer meets, else the step's default. Null when there is none: the form is done. Rules are
|
|
197
|
+
* tried in the order of the step's questions, then of each question's options (the order its
|
|
198
|
+
* answers are read in), never by the rules' own order: stored JSON does not keep an object's keys
|
|
199
|
+
* in order.
|
|
197
200
|
*/
|
|
198
201
|
export function nextStep(step, answers, explicit) {
|
|
199
202
|
if (typeof explicit === 'string' && explicit) return explicit;
|
|
200
203
|
const rules = isObject(step?.next?.answers) ? step.next.answers : {};
|
|
201
|
-
for (const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
for (const b of step?.blocks ?? [])
|
|
205
|
+
for (const value of answers[b?.question] ?? []) {
|
|
206
|
+
const to = rules[`${b.question}=${value}`];
|
|
207
|
+
if (typeof to === 'string') return to;
|
|
208
|
+
}
|
|
206
209
|
return typeof step?.next?.default === 'string' ? step.next.default : null;
|
|
207
210
|
}
|
|
208
211
|
|
|
@@ -258,6 +261,30 @@ export function select(make, label, blank, pairs, chosen) {
|
|
|
258
261
|
return s;
|
|
259
262
|
}
|
|
260
263
|
|
|
264
|
+
/** A choice's options the renderer can show: each with a string value. */
|
|
265
|
+
export const optionsOf = (q) =>
|
|
266
|
+
(q.options ?? []).filter((x) => isObject(x) && typeof x.value === 'string');
|
|
267
|
+
|
|
268
|
+
/** One option of choice `q` as its input, named `q_<key>` and checked when it was chosen. */
|
|
269
|
+
export function choiceInput(make, q, x, chosen, type) {
|
|
270
|
+
const input = make('input');
|
|
271
|
+
input.type = type;
|
|
272
|
+
input.name = `q_${q.key}`;
|
|
273
|
+
input.value = x.value;
|
|
274
|
+
input.checked = chosen.includes(x.value);
|
|
275
|
+
return input;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* How many of choice `q`'s picture cards a row holds where the form is wide (answers.js `.qc`), by
|
|
280
|
+
* its options: all of them up to four, else three or four so the last row is fullest. The renderer
|
|
281
|
+
* sizes their pictures by it too.
|
|
282
|
+
*/
|
|
283
|
+
export const cardColumns = (q) => {
|
|
284
|
+
const n = q.options.length;
|
|
285
|
+
return n <= 4 ? n : n % 4 ? 3 : 4;
|
|
286
|
+
};
|
|
287
|
+
|
|
261
288
|
/** Where a wheel must come to rest, in degrees clockwise, for prize `index` to sit under the top pointer. */
|
|
262
289
|
export function restAngle(index, segments, turns = 5) {
|
|
263
290
|
const size = 360 / segments;
|