@thg-agentic/hyper-personalisation-web 3.22.2 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -1
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -0
- package/dist/onsite-render.min.js +178 -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/hp.js +2 -0
- package/src/index.js +96 -1
- 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 +446 -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 +1234 -0
- package/src/onsite/rich-text.js +73 -0
- package/src/onsite/rules.js +237 -0
- package/src/onsite/styles.js +208 -0
- package/src/onsite/triggers.js +165 -0
- package/src/onsite/wheel-loader.js +5 -0
- package/src/onsite/wheel.js +148 -0
- package/src/onsite-facade.js +99 -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 +195 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onsite forms (ADR 0064): sign-up popups, bars, teasers, inline forms and spin-to-win, shown by
|
|
3
|
+
* the settings' `hp.onsite` index and decided by the server.
|
|
4
|
+
*
|
|
5
|
+
* import { startOnsite } from '@thg-agentic/hyper-personalisation-web/onsite';
|
|
6
|
+
* startOnsite(hp); // the client bootstrap resolved to, or pass `onsite: true` to bootstrap
|
|
7
|
+
*
|
|
8
|
+
* The browser judges the trigger; the server decides the rest. Each live form's trigger is watched
|
|
9
|
+
* here, with its pages, devices, dates and caps. When one fires, `POST /v1/decisions` is asked for
|
|
10
|
+
* that one slot: a held-out visitor, or no decision, shows nothing. One overlay per page view, the
|
|
11
|
+
* highest priority among those that fire within 50 ms; one that cannot show passes to the next, but
|
|
12
|
+
* a held-out visitor sees none; with the property's `overlay_gap_minutes`,
|
|
13
|
+
* none until that long after the last one this session. `?hp_debug=onsite` logs why each form did
|
|
14
|
+
* or did not show; `hp.onsite.debug()` returns it. The renderer (./render.js, its own chunk) is
|
|
15
|
+
* fetched only once a trigger fires, alongside the decision request; a page whose forms never fire
|
|
16
|
+
* never downloads it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { loadAnswers } from './answers-loader.js';
|
|
20
|
+
import { createApi } from './api.js';
|
|
21
|
+
import { createCounters } from './counters.js';
|
|
22
|
+
import { loadRender } from './render-loader.js';
|
|
23
|
+
import {
|
|
24
|
+
blockers,
|
|
25
|
+
byPriority,
|
|
26
|
+
deviceOf,
|
|
27
|
+
INTERRUPTS,
|
|
28
|
+
OVERLAY,
|
|
29
|
+
readCreative,
|
|
30
|
+
readForm,
|
|
31
|
+
} from './rules.js';
|
|
32
|
+
import { watch } from './triggers.js';
|
|
33
|
+
import { loadWheel } from './wheel-loader.js';
|
|
34
|
+
|
|
35
|
+
const ACCESS = Symbol.for(
|
|
36
|
+
'@thg-agentic/hyper-personalisation-web.onsite-access',
|
|
37
|
+
);
|
|
38
|
+
const CONTROLLER = Symbol.for('@thg-agentic/hyper-personalisation-web.onsite');
|
|
39
|
+
const PENDING = Symbol.for(
|
|
40
|
+
'@thg-agentic/hyper-personalisation-web.onsite.pending',
|
|
41
|
+
);
|
|
42
|
+
const LISTENERS = Symbol.for(
|
|
43
|
+
'@thg-agentic/hyper-personalisation-web.onsite.listeners',
|
|
44
|
+
);
|
|
45
|
+
const SUPPRESSED = Symbol.for(
|
|
46
|
+
'@thg-agentic/hyper-personalisation-web.onsite.suppressed',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** A preview link's counters: a trader's visit counts nothing and is capped by nothing. */
|
|
50
|
+
const INERT = {
|
|
51
|
+
seen: () => ({ session: 0, lifetime: 0, closedAt: null, submitted: false }),
|
|
52
|
+
subscribed: () => false,
|
|
53
|
+
shown() {},
|
|
54
|
+
closed() {},
|
|
55
|
+
submitted() {},
|
|
56
|
+
tab() {},
|
|
57
|
+
tabOut: () => false,
|
|
58
|
+
pin: () => null,
|
|
59
|
+
coveredAt: () => null,
|
|
60
|
+
covered() {},
|
|
61
|
+
code: () => null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** How long a fired trigger waits for others, so two forms entering together show the higher priority. */
|
|
65
|
+
const FIRE_WINDOW_MS = 50;
|
|
66
|
+
|
|
67
|
+
const debugParam = () => {
|
|
68
|
+
try {
|
|
69
|
+
return new URLSearchParams(location.search).get('hp_debug') === 'onsite';
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Start onsite forms for `client`, once per page. Returns the controller, or null without a
|
|
77
|
+
* browser or a client from this SDK.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} client The SDK client (bootstrap or createAnalytics).
|
|
80
|
+
* @param {import('../../types/onsite').OnsiteOptions} [options]
|
|
81
|
+
*/
|
|
82
|
+
export function startOnsite(client, options = {}) {
|
|
83
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
84
|
+
return null;
|
|
85
|
+
if (window[CONTROLLER]) return window[CONTROLLER];
|
|
86
|
+
const access =
|
|
87
|
+
typeof client?.[ACCESS] === 'function' ? client[ACCESS]() : null;
|
|
88
|
+
if (!access) {
|
|
89
|
+
console.warn(
|
|
90
|
+
'[hp] onsite: startOnsite needs the client bootstrap or createAnalytics made',
|
|
91
|
+
);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const settings = options.settings ?? access.settings ?? {};
|
|
95
|
+
const api = createApi(access, {
|
|
96
|
+
...options,
|
|
97
|
+
challenge: settings.capture?.challenge,
|
|
98
|
+
});
|
|
99
|
+
const counters = access.preview
|
|
100
|
+
? INERT
|
|
101
|
+
: createCounters(access.storageKey, access.personalisation);
|
|
102
|
+
const mounted = new Map();
|
|
103
|
+
const suppressed = () => window[SUPPRESSED]?.size > 0;
|
|
104
|
+
|
|
105
|
+
/** Tell the page's `hp.onsite.on` listeners; a listener that throws never reaches the form. */
|
|
106
|
+
const emit = (name, detail) => {
|
|
107
|
+
for (const fn of window[LISTENERS]?.[name] ?? []) {
|
|
108
|
+
try {
|
|
109
|
+
fn(detail);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.warn(`[hp] onsite ${name} listener failed:`, err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const device = deviceOf(window);
|
|
116
|
+
const logging = access.debug || options.debug === true || debugParam();
|
|
117
|
+
const trace = new Map();
|
|
118
|
+
const stops = new Map();
|
|
119
|
+
const forms = [];
|
|
120
|
+
for (const raw of Array.isArray(settings.forms) ? settings.forms : []) {
|
|
121
|
+
const form = readForm(raw);
|
|
122
|
+
if (form) forms.push(form);
|
|
123
|
+
else
|
|
124
|
+
trace.set(String(raw?.slot ?? `form ${trace.size + 1}`), {
|
|
125
|
+
slot: String(raw?.slot ?? ''),
|
|
126
|
+
state: 'invalid',
|
|
127
|
+
reasons: ['not a form this SDK can show'],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
let overlay = null;
|
|
131
|
+
let fired = [];
|
|
132
|
+
const gap = Number.isInteger(settings.overlay_gap_minutes)
|
|
133
|
+
? settings.overlay_gap_minutes
|
|
134
|
+
: 0;
|
|
135
|
+
/** Why the property's gap between covering forms still holds this session, or null. */
|
|
136
|
+
const gapHolds = () => {
|
|
137
|
+
const at = counters.coveredAt();
|
|
138
|
+
if (!(gap > 0) || typeof at !== 'number') return null;
|
|
139
|
+
const ago = Math.max(0, Math.floor((Date.now() - at) / 60_000));
|
|
140
|
+
return ago < gap
|
|
141
|
+
? `a popup showed ${ago} min ago; ${gap} min between them`
|
|
142
|
+
: null;
|
|
143
|
+
};
|
|
144
|
+
/** Forms whose trigger fired on an empty basket: they try again when the basket changes. */
|
|
145
|
+
const awaitingBasket = new Set();
|
|
146
|
+
/** Covering forms whose trigger fired before the consent banner was answered. */
|
|
147
|
+
const awaitingConsent = new Set();
|
|
148
|
+
|
|
149
|
+
const note = (form, state, reasons = []) => {
|
|
150
|
+
trace.set(form.slot, {
|
|
151
|
+
slot: form.slot,
|
|
152
|
+
format: form.format,
|
|
153
|
+
trigger: form.trigger.type,
|
|
154
|
+
priority: form.priority,
|
|
155
|
+
state,
|
|
156
|
+
reasons,
|
|
157
|
+
});
|
|
158
|
+
if (logging)
|
|
159
|
+
console.info(
|
|
160
|
+
`[hp] onsite ${form.slot}: ${state}${reasons.length ? ` (${reasons.join('; ')})` : ''}`,
|
|
161
|
+
);
|
|
162
|
+
return state;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const sessionAudiences = () => {
|
|
166
|
+
try {
|
|
167
|
+
const given = options.audiences?.();
|
|
168
|
+
return Array.isArray(given) ? given : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const env = (form) => ({
|
|
175
|
+
path: location.pathname,
|
|
176
|
+
device,
|
|
177
|
+
now: Date.now(),
|
|
178
|
+
seen: counters.seen(form.slot),
|
|
179
|
+
subscribed: counters.subscribed() || options.subscribed?.() === true,
|
|
180
|
+
personalisation: access.personalisation(),
|
|
181
|
+
preview: Boolean(access.preview),
|
|
182
|
+
audiences: sessionAudiences(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const unwatch = (form) => {
|
|
186
|
+
stops.get(form.slot)?.();
|
|
187
|
+
stops.delete(form.slot);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** `kept`: a teaser's tab carried from an earlier page, shown again whatever its caps. */
|
|
191
|
+
/** Show `form` if its rules allow it; resolves to the state its trace records. */
|
|
192
|
+
async function show(form, reason, kept = false) {
|
|
193
|
+
if (suppressed()) {
|
|
194
|
+
return note(form, 'not shown', [
|
|
195
|
+
`suppressed: ${[...window[SUPPRESSED]].join(', ')}`,
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
const reasons = blockers(
|
|
199
|
+
form,
|
|
200
|
+
kept ? { ...env(form), seen: INERT.seen() } : env(form),
|
|
201
|
+
);
|
|
202
|
+
if (reasons.length) {
|
|
203
|
+
return note(form, 'not eligible', reasons);
|
|
204
|
+
}
|
|
205
|
+
// The gap first: it holds for the whole session, so it is the reason worth reading.
|
|
206
|
+
const interrupts = INTERRUPTS(form.format) && !kept;
|
|
207
|
+
const wait = interrupts && gapHolds();
|
|
208
|
+
if (wait) {
|
|
209
|
+
return note(form, 'not shown', [wait]);
|
|
210
|
+
}
|
|
211
|
+
// Never over an unanswered consent banner: the shopper answers it first, whatever the answer.
|
|
212
|
+
if (interrupts && !access.preview && access.consentAnswered?.() === false) {
|
|
213
|
+
awaitingConsent.add(form);
|
|
214
|
+
return note(form, 'not shown', ['waiting for the consent banner']);
|
|
215
|
+
}
|
|
216
|
+
const covers = OVERLAY(form.format);
|
|
217
|
+
if (covers && overlay) {
|
|
218
|
+
return note(form, 'not shown', [`${overlay} is showing`]);
|
|
219
|
+
}
|
|
220
|
+
let container = null;
|
|
221
|
+
if (!covers) {
|
|
222
|
+
try {
|
|
223
|
+
container = document.querySelector(form.trigger.selector ?? '');
|
|
224
|
+
} catch {
|
|
225
|
+
container = null;
|
|
226
|
+
}
|
|
227
|
+
if (!container) {
|
|
228
|
+
return note(form, 'not shown', ['no element matches its selector']);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (covers) overlay = form.slot;
|
|
232
|
+
unwatch(form);
|
|
233
|
+
note(form, 'asking for a decision', [reason]);
|
|
234
|
+
// The renderer is fetched while the decision is asked, so neither waits on the other.
|
|
235
|
+
const renderer = loadRender().catch((err) => {
|
|
236
|
+
console.warn('[hp] onsite renderer not loaded:', err?.message ?? err);
|
|
237
|
+
return null;
|
|
238
|
+
});
|
|
239
|
+
const res = await api.decide(form.slot, counters.pin(form.slot));
|
|
240
|
+
const decision =
|
|
241
|
+
res.status === 200
|
|
242
|
+
? res.body?.decisions?.find((d) => d?.slot === form.slot)
|
|
243
|
+
: null;
|
|
244
|
+
// The version this visit was shown in the form's A/B test, kept for the rest of it (ADR 0065).
|
|
245
|
+
if (decision?.experiment && decision.arm && decision.split)
|
|
246
|
+
counters.pin(form.slot, [
|
|
247
|
+
decision.experiment,
|
|
248
|
+
decision.arm,
|
|
249
|
+
decision.split,
|
|
250
|
+
]);
|
|
251
|
+
const creative =
|
|
252
|
+
decision && decision.holdout !== true
|
|
253
|
+
? readCreative(decision.payload)
|
|
254
|
+
: null;
|
|
255
|
+
// A reminder needs a code to remind of, and a basket bar a basket: without one, nothing shows.
|
|
256
|
+
const first = creative?.steps[0].blocks ?? [];
|
|
257
|
+
const coupon =
|
|
258
|
+
decision?.offer?.code ?? (access.preview ? 'PREVIEW' : counters.code());
|
|
259
|
+
const basket = access.basketValue?.() ?? null;
|
|
260
|
+
const missing =
|
|
261
|
+
(first.some((b) => b?.t === 'code') &&
|
|
262
|
+
!coupon &&
|
|
263
|
+
'no code to remind of') ||
|
|
264
|
+
(first.some((b) => b?.t === 'progress') &&
|
|
265
|
+
!(basket > 0) &&
|
|
266
|
+
'the basket is empty');
|
|
267
|
+
if (missing) {
|
|
268
|
+
if (covers) overlay = null;
|
|
269
|
+
if (missing === 'the basket is empty') awaitingBasket.add(form);
|
|
270
|
+
return note(form, 'not shown', [missing]);
|
|
271
|
+
}
|
|
272
|
+
awaitingBasket.delete(form);
|
|
273
|
+
if (!creative) {
|
|
274
|
+
// A held-out visitor whose trigger fired is exposed to the default, as a treated one is.
|
|
275
|
+
if (decision?.holdout === true && access.analytics())
|
|
276
|
+
client.recordExposure(res.body);
|
|
277
|
+
if (covers) overlay = null;
|
|
278
|
+
return note(
|
|
279
|
+
form,
|
|
280
|
+
decision?.holdout === true ? 'held out' : 'no decision',
|
|
281
|
+
[
|
|
282
|
+
res.status === 200
|
|
283
|
+
? 'no creative for this visitor'
|
|
284
|
+
: `decisions answered ${res.status}`,
|
|
285
|
+
],
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const render = await renderer;
|
|
289
|
+
if (!render) {
|
|
290
|
+
if (covers) overlay = null;
|
|
291
|
+
return note(form, 'not shown', ['the renderer did not load']);
|
|
292
|
+
}
|
|
293
|
+
if (!kept) counters.shown(form.slot);
|
|
294
|
+
if (interrupts) counters.covered();
|
|
295
|
+
const tell = (name, detail = {}) =>
|
|
296
|
+
emit(name, {
|
|
297
|
+
slot: form.slot,
|
|
298
|
+
arm: decision.arm ?? null,
|
|
299
|
+
format: creative.format ?? form.format,
|
|
300
|
+
...detail,
|
|
301
|
+
});
|
|
302
|
+
const handle = render.mountForm({
|
|
303
|
+
form,
|
|
304
|
+
creative,
|
|
305
|
+
decision,
|
|
306
|
+
access,
|
|
307
|
+
client,
|
|
308
|
+
api,
|
|
309
|
+
counters,
|
|
310
|
+
loadWheel,
|
|
311
|
+
loadAnswers,
|
|
312
|
+
container,
|
|
313
|
+
coupon,
|
|
314
|
+
basket: () => access.basketValue?.() ?? null,
|
|
315
|
+
emit: tell,
|
|
316
|
+
onClose(why) {
|
|
317
|
+
mounted.delete(form.slot);
|
|
318
|
+
if (overlay === form.slot) overlay = null;
|
|
319
|
+
note(form, 'closed', [why]);
|
|
320
|
+
tell('closed', { reason: why });
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
mounted.set(form.slot, handle);
|
|
324
|
+
if (access.analytics()) client.recordExposure(res.body);
|
|
325
|
+
note(form, 'shown', [reason]);
|
|
326
|
+
tell('shown');
|
|
327
|
+
return 'shown';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Triggers that fire within FIRE_WINDOW_MS of each other compete: only the highest priority shows.
|
|
332
|
+
* One that cannot show passes to the next; a held-out visitor sees none, so the holdout stays clean.
|
|
333
|
+
*/
|
|
334
|
+
function fire(form, reason) {
|
|
335
|
+
if (!OVERLAY(form.format)) return show(form, reason);
|
|
336
|
+
fired.push([form, reason]);
|
|
337
|
+
if (fired.length > 1) return;
|
|
338
|
+
setTimeout(async () => {
|
|
339
|
+
const queue = fired.sort(([a], [b]) => byPriority(a, b));
|
|
340
|
+
fired = [];
|
|
341
|
+
for (const [i, [next, why]] of queue.entries()) {
|
|
342
|
+
const state = await show(next, why);
|
|
343
|
+
if (state === 'shown' || state === 'held out') {
|
|
344
|
+
for (const [f] of queue.slice(i + 1))
|
|
345
|
+
note(f, 'not shown', [`${next.slot} has a higher priority`]);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}, FIRE_WINDOW_MS);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const form of forms) {
|
|
353
|
+
// A teaser's tab still out from an earlier page comes back whatever its caps and trigger.
|
|
354
|
+
if (form.format === 'teaser' && counters.tabOut(form.slot)) {
|
|
355
|
+
show(form, 'tab kept from an earlier page', true);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const reasons = blockers(form, env(form));
|
|
359
|
+
if (reasons.length) {
|
|
360
|
+
note(form, 'not eligible', reasons);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const type = form.trigger.type;
|
|
364
|
+
if (type === 'manual') note(form, 'waiting for hp.onsite.open');
|
|
365
|
+
else if (type === 'inline' || form.format === 'inline')
|
|
366
|
+
show(form, 'inline');
|
|
367
|
+
else arm(form);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Watch a form's trigger; when it fires, the form competes to show. */
|
|
371
|
+
function arm(form) {
|
|
372
|
+
// A form armed again (after the consent banner) stops its first watcher, so it fires once.
|
|
373
|
+
unwatch(form);
|
|
374
|
+
stops.set(
|
|
375
|
+
form.slot,
|
|
376
|
+
watch(
|
|
377
|
+
form.trigger,
|
|
378
|
+
(reason) => {
|
|
379
|
+
unwatch(form);
|
|
380
|
+
fire(form, reason);
|
|
381
|
+
},
|
|
382
|
+
{ device },
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
note(form, 'armed', [`${form.trigger.type} on ${device}`]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Once the banner is answered, a form that waited is armed again, so it opens at its trigger's
|
|
389
|
+
// next moment (an exit form at the next exit), not at once; a form the site opened is shown.
|
|
390
|
+
const stopConsent = access.onConsentAnswered?.(() => {
|
|
391
|
+
const waiting = [...awaitingConsent];
|
|
392
|
+
awaitingConsent.clear();
|
|
393
|
+
for (const form of waiting)
|
|
394
|
+
if (!mounted.has(form.slot))
|
|
395
|
+
form.trigger.type === 'manual' ? fire(form, 'manual') : arm(form);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const stopBasket = access.onBasket?.(() => {
|
|
399
|
+
if (!((access.basketValue?.() ?? 0) > 0)) return;
|
|
400
|
+
// Out of the set before the decision is asked, so a second update cannot show them twice;
|
|
401
|
+
// covering ones compete on priority as any trigger does.
|
|
402
|
+
const waiting = [...awaitingBasket];
|
|
403
|
+
awaitingBasket.clear();
|
|
404
|
+
for (const form of waiting)
|
|
405
|
+
if (!mounted.has(form.slot)) fire(form, 'the basket changed');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const controller = Object.freeze({
|
|
409
|
+
/** Show the form on `slot` now, whatever its trigger, if its rules allow it. */
|
|
410
|
+
open(slot) {
|
|
411
|
+
const form = forms.find((f) => f.slot === slot);
|
|
412
|
+
// One already on screen (an inline form) is shown, not mounted twice.
|
|
413
|
+
return form && !mounted.has(slot)
|
|
414
|
+
? show(form, 'manual').then((state) => state === 'shown')
|
|
415
|
+
: Promise.resolve(mounted.has(slot));
|
|
416
|
+
},
|
|
417
|
+
/** `hp.onsite.suppress`: take every form off the screen, quietly; none shows until unsuppressed. */
|
|
418
|
+
suppress() {
|
|
419
|
+
for (const [slot, handle] of mounted) {
|
|
420
|
+
handle.destroy();
|
|
421
|
+
mounted.delete(slot);
|
|
422
|
+
if (overlay === slot) overlay = null;
|
|
423
|
+
const form = forms.find((f) => f.slot === slot);
|
|
424
|
+
if (form) note(form, 'closed', ['suppressed']);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
/** Each form's state on this page and why, in settings order. */
|
|
428
|
+
debug: () =>
|
|
429
|
+
[...trace.values()].map((t) => ({ ...t, reasons: [...t.reasons] })),
|
|
430
|
+
/** Stop watching every trigger. A form on screen stays until it is closed. */
|
|
431
|
+
stop() {
|
|
432
|
+
for (const stop of stops.values()) stop();
|
|
433
|
+
stops.clear();
|
|
434
|
+
stopBasket?.();
|
|
435
|
+
stopConsent?.();
|
|
436
|
+
awaitingBasket.clear();
|
|
437
|
+
awaitingConsent.clear();
|
|
438
|
+
if (window[CONTROLLER] === controller) delete window[CONTROLLER];
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
window[CONTROLLER] = controller;
|
|
442
|
+
for (const { slot, resolve } of window[PENDING]?.splice(0) ?? [])
|
|
443
|
+
resolve(controller.open(slot));
|
|
444
|
+
if (logging) console.table?.(controller.debug());
|
|
445
|
+
return controller;
|
|
446
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ALTCHA's proof of work (the v1 SHA-256 challenge, MIT), solved headless: find `number` in
|
|
3
|
+
* [0, maxnumber] with `sha256(salt + number) === challenge`. The answer is the base64 payload
|
|
4
|
+
* `/v1/capture` takes as `altcha`. Off the main thread in a Worker made from a Blob; where a CSP or
|
|
5
|
+
* the browser refuses one, on the main thread, yielding to the page between batches.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TIMEOUT_MS = 20_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Self-contained, so it can be the Worker's body: SubtleCrypto, 256 hashes at a time. `pause`
|
|
12
|
+
* yields between batches on the main thread; an aborted `signal` stops it there, with null.
|
|
13
|
+
*
|
|
14
|
+
* @param {{ challenge: string, salt: string, maxnumber?: number }} c
|
|
15
|
+
* @param {() => Promise<void>} [pause]
|
|
16
|
+
* @param {AbortSignal} [signal]
|
|
17
|
+
*/
|
|
18
|
+
export async function solveChallenge(c, pause, signal) {
|
|
19
|
+
const hex = (buf) =>
|
|
20
|
+
Array.from(new Uint8Array(buf), (b) =>
|
|
21
|
+
b.toString(16).padStart(2, '0'),
|
|
22
|
+
).join('');
|
|
23
|
+
const encoder = new TextEncoder();
|
|
24
|
+
const max = c.maxnumber ?? 1e6;
|
|
25
|
+
for (let start = 0; start <= max; start += 256) {
|
|
26
|
+
const batch = [];
|
|
27
|
+
for (let n = start; n < start + 256 && n <= max; n++)
|
|
28
|
+
batch.push(crypto.subtle.digest('SHA-256', encoder.encode(c.salt + n)));
|
|
29
|
+
const found = (await Promise.all(batch)).findIndex(
|
|
30
|
+
(d) => hex(d) === c.challenge,
|
|
31
|
+
);
|
|
32
|
+
if (found >= 0) return start + found;
|
|
33
|
+
if (pause) await pause();
|
|
34
|
+
if (signal?.aborted) return null;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A turn for the page between batches: a `user-visible` task, queued behind the page's own, with no
|
|
41
|
+
* timer's 4 ms clamp (the proof ends 1.4 to 2x sooner than with setTimeout). Not `background`,
|
|
42
|
+
* which a busy page starves until the proof times out, nor `scheduler.yield`, whose continuation
|
|
43
|
+
* runs ahead of the page's tasks and held off its timers for the whole proof.
|
|
44
|
+
*/
|
|
45
|
+
const pause = () =>
|
|
46
|
+
globalThis.scheduler?.postTask?.(() => {}, { priority: 'user-visible' }) ??
|
|
47
|
+
new Promise((resolve) => setTimeout(resolve, 0));
|
|
48
|
+
|
|
49
|
+
/** On the main thread, giving the page a turn between batches, until `signal` aborts. */
|
|
50
|
+
export const solveOnMainThread = (c, signal) =>
|
|
51
|
+
solveChallenge(c, pause, signal);
|
|
52
|
+
|
|
53
|
+
function solveWithWorker(c, signal) {
|
|
54
|
+
const source = `onmessage=async(e)=>postMessage(await(${solveChallenge})(e.data))`;
|
|
55
|
+
const url = URL.createObjectURL(
|
|
56
|
+
new Blob([source], { type: 'text/javascript' }),
|
|
57
|
+
);
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let worker;
|
|
60
|
+
try {
|
|
61
|
+
worker = new Worker(url);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
URL.revokeObjectURL(url);
|
|
64
|
+
reject(err);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const done = (fn, value) => {
|
|
68
|
+
worker.terminate();
|
|
69
|
+
URL.revokeObjectURL(url);
|
|
70
|
+
fn(value);
|
|
71
|
+
};
|
|
72
|
+
signal.addEventListener('abort', () => done(resolve, null), {
|
|
73
|
+
once: true,
|
|
74
|
+
});
|
|
75
|
+
worker.onmessage = (e) => done(resolve, e.data);
|
|
76
|
+
worker.onerror = (e) => {
|
|
77
|
+
e.preventDefault?.();
|
|
78
|
+
done(reject, e);
|
|
79
|
+
};
|
|
80
|
+
worker.postMessage({
|
|
81
|
+
challenge: c.challenge,
|
|
82
|
+
salt: c.salt,
|
|
83
|
+
maxnumber: c.maxnumber,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isChallenge = (c) =>
|
|
89
|
+
c &&
|
|
90
|
+
c.algorithm === 'SHA-256' &&
|
|
91
|
+
typeof c.challenge === 'string' &&
|
|
92
|
+
typeof c.salt === 'string' &&
|
|
93
|
+
typeof c.signature === 'string';
|
|
94
|
+
|
|
95
|
+
/** The `altcha` payload for challenge `c`, or null when it cannot be solved in time. */
|
|
96
|
+
export async function solve(c) {
|
|
97
|
+
// Without SubtleCrypto (not a secure context) there is no proof: the gateway decides.
|
|
98
|
+
if (!isChallenge(c) || typeof crypto?.subtle?.digest !== 'function')
|
|
99
|
+
return null;
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
// Out of time, the proof stops: the worker is ended, the main thread's batches cease.
|
|
102
|
+
const stop = new AbortController();
|
|
103
|
+
let timer;
|
|
104
|
+
const timeout = new Promise((resolve) => {
|
|
105
|
+
timer = setTimeout(() => resolve(null), TIMEOUT_MS);
|
|
106
|
+
});
|
|
107
|
+
const work =
|
|
108
|
+
typeof Worker === 'function' && typeof Blob === 'function'
|
|
109
|
+
? solveWithWorker(c, stop.signal).catch(() =>
|
|
110
|
+
solveOnMainThread(c, stop.signal),
|
|
111
|
+
)
|
|
112
|
+
: solveOnMainThread(c, stop.signal);
|
|
113
|
+
const number = await Promise.race([work, timeout]);
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
stop.abort();
|
|
116
|
+
if (typeof number !== 'number') return null;
|
|
117
|
+
return btoa(
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
algorithm: c.algorithm,
|
|
120
|
+
challenge: c.challenge,
|
|
121
|
+
number,
|
|
122
|
+
salt: c.salt,
|
|
123
|
+
signature: c.signature,
|
|
124
|
+
took: Date.now() - started,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `./onsite/preview`: the console's preview of a creative, drawn by the shoppers' own renderer
|
|
3
|
+
* inside the console's box instead of over the page. It makes no request, sends no event and counts
|
|
4
|
+
* nothing: its buttons only move between steps, and closing does nothing. A popup, sheet, bar or
|
|
5
|
+
* teaser sits inside `host` as it would in the viewport (the host contains its fixed layers), and
|
|
6
|
+
* `device` picks the small-screen layout whatever the console's own width.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { answerFields, answerOf } from './answers.js';
|
|
10
|
+
import { mountForm } from './render.js';
|
|
11
|
+
import { readCreative } from './rules.js';
|
|
12
|
+
import { mountWheel } from './wheel.js';
|
|
13
|
+
|
|
14
|
+
export { CALLING_CODES } from './rules.js';
|
|
15
|
+
|
|
16
|
+
const SAMPLE_CONSENT = 'Your consent text appears here.';
|
|
17
|
+
const SAMPLE_SMS_CONSENT = 'Your SMS consent text appears here.';
|
|
18
|
+
const noop = () => {};
|
|
19
|
+
const silent = { analytics: () => false };
|
|
20
|
+
const inert = {
|
|
21
|
+
submitted: noop,
|
|
22
|
+
closed: noop,
|
|
23
|
+
shown: noop,
|
|
24
|
+
tab: noop,
|
|
25
|
+
code: noop,
|
|
26
|
+
};
|
|
27
|
+
/** A basket to show a progress block against, when the console gives none. */
|
|
28
|
+
const SAMPLE_BASKET = 32;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render `creative` into `host`.
|
|
32
|
+
*
|
|
33
|
+
* @param {HTMLElement} host The console's element; the preview fills it.
|
|
34
|
+
* @param {unknown} creative The arm's payload.
|
|
35
|
+
* @param {{ step?: string, device?: 'desktop'|'mobile', consentText?: string, smsConsentText?: string, locale?: string,
|
|
36
|
+
* questions?: Array<{ key: string, type?: 'one'|'many'|'date'|'number', label?: string, options?: Array<{ value: string, label?: string }>, format?: object }>,
|
|
37
|
+
* prizes?: Array<{ key: string, label: string }>, basketValue?: number }} [options]
|
|
38
|
+
* @returns {{ update(creative: unknown, options?: object): void, destroy(): void, step(): string|null }}
|
|
39
|
+
*/
|
|
40
|
+
export function mountPreview(host, creative, options = {}) {
|
|
41
|
+
let mounted = null;
|
|
42
|
+
let last = options;
|
|
43
|
+
|
|
44
|
+
const render = (doc, opts) => {
|
|
45
|
+
const read = readCreative(doc);
|
|
46
|
+
mounted?.destroy();
|
|
47
|
+
mounted = null;
|
|
48
|
+
if (!read) return;
|
|
49
|
+
const format = read.format ?? 'popup';
|
|
50
|
+
mounted = mountForm({
|
|
51
|
+
form: {
|
|
52
|
+
slot: 'preview',
|
|
53
|
+
format,
|
|
54
|
+
// The settings' `locale`, so the preview orders a birthday and picks a phone's
|
|
55
|
+
// country as shoppers see them, not by the console's own language.
|
|
56
|
+
...(opts.locale ? { locale: opts.locale } : {}),
|
|
57
|
+
consent_text: { text: opts.consentText ?? SAMPLE_CONSENT },
|
|
58
|
+
sms_consent_text: { text: opts.smsConsentText ?? SAMPLE_SMS_CONSENT },
|
|
59
|
+
questions: Array.isArray(opts.questions) ? opts.questions : [],
|
|
60
|
+
prizes: Array.isArray(opts.prizes) ? opts.prizes : [],
|
|
61
|
+
},
|
|
62
|
+
creative: read,
|
|
63
|
+
decision: { slot: 'preview' },
|
|
64
|
+
access: silent,
|
|
65
|
+
client: null,
|
|
66
|
+
api: null,
|
|
67
|
+
counters: inert,
|
|
68
|
+
loadWheel: () => Promise.resolve({ mountWheel }),
|
|
69
|
+
loadAnswers: () => Promise.resolve({ answerFields, answerOf }),
|
|
70
|
+
basket: () =>
|
|
71
|
+
Number.isFinite(opts.basketValue) ? opts.basketValue : SAMPLE_BASKET,
|
|
72
|
+
container: host,
|
|
73
|
+
preview: {
|
|
74
|
+
device: opts.device === 'mobile' ? 'mobile' : 'desktop',
|
|
75
|
+
step: opts.step,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
render(creative, options);
|
|
81
|
+
return {
|
|
82
|
+
/** Re-render, on the step shown unless `options.step` names another. */
|
|
83
|
+
update(next, opts = {}) {
|
|
84
|
+
last = {
|
|
85
|
+
...last,
|
|
86
|
+
...opts,
|
|
87
|
+
step: opts.step ?? mounted?.step() ?? undefined,
|
|
88
|
+
};
|
|
89
|
+
render(next, last);
|
|
90
|
+
},
|
|
91
|
+
destroy() {
|
|
92
|
+
mounted?.destroy();
|
|
93
|
+
mounted = null;
|
|
94
|
+
},
|
|
95
|
+
step: () => mounted?.step() ?? null,
|
|
96
|
+
};
|
|
97
|
+
}
|