@thg-agentic/hyper-personalisation-web 3.22.2 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -2
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -0
- package/dist/onsite-render.min.js +182 -0
- package/dist/onsite-wheel.min.js +1 -0
- package/dist/onsite.min.js +1 -0
- package/dist/snippet.min.js +1 -1
- package/package.json +19 -3
- package/src/bootstrap.js +33 -2
- package/src/context.js +20 -7
- package/src/ga4.js +3 -4
- package/src/hp.js +2 -0
- package/src/index.js +100 -5
- package/src/onsite/answers-loader.js +6 -0
- package/src/onsite/answers.js +177 -0
- package/src/onsite/api.js +93 -0
- package/src/onsite/counters.js +144 -0
- package/src/onsite/index.js +525 -0
- package/src/onsite/pow.js +127 -0
- package/src/onsite/preview.js +97 -0
- package/src/onsite/render-loader.js +5 -0
- package/src/onsite/render.js +1507 -0
- package/src/onsite/rich-text.js +73 -0
- package/src/onsite/rules.js +250 -0
- package/src/onsite/styles.js +212 -0
- package/src/onsite/triggers.js +165 -0
- package/src/onsite/wheel-loader.js +5 -0
- package/src/onsite/wheel.js +196 -0
- package/src/onsite-facade.js +123 -0
- package/src/onsite-loader.js +5 -0
- package/src/settings.js +18 -0
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +5 -0
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +15 -1
- package/types/events.d.ts +68 -5
- package/types/hp.d.ts +4 -1
- package/types/index.d.ts +10 -0
- package/types/onsite-preview.d.ts +58 -0
- package/types/onsite-wheel.d.ts +11 -0
- package/types/onsite.d.ts +203 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a form's trigger fires. Exit intent follows ouibounce and use-exit-intent (both MIT; the
|
|
3
|
+
* thresholds are ours, docs/design/onsite-capture-research.md §7):
|
|
4
|
+
*
|
|
5
|
+
* - desktop: armed `delay_ms` (5 s) after the start and once the pointer has been below the top
|
|
6
|
+
* tenth of the viewport; fires when it leaves the document through the top edge, moving up, with
|
|
7
|
+
* no field focused.
|
|
8
|
+
* - mobile: after 30% of the page has been scrolled, an upward flick of 120px or more at 0.5 px/ms
|
|
9
|
+
* or faster within 300 ms (reaching for the address bar or back), or a return to the tab after
|
|
10
|
+
* 10 s or more away. Never on hide.
|
|
11
|
+
*
|
|
12
|
+
* Every watcher returns what stops it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const EXIT_ARM_MS = 5000;
|
|
16
|
+
const FLICK_PX = 120;
|
|
17
|
+
const FLICK_PX_PER_MS = 0.5;
|
|
18
|
+
const FLICK_WINDOW_MS = 300;
|
|
19
|
+
const EXIT_DEPTH = 0.3;
|
|
20
|
+
const AWAY_MS = 10_000;
|
|
21
|
+
const INPUTS = [
|
|
22
|
+
'mousemove',
|
|
23
|
+
'pointerdown',
|
|
24
|
+
'keydown',
|
|
25
|
+
'scroll',
|
|
26
|
+
'touchstart',
|
|
27
|
+
'wheel',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function listeners() {
|
|
31
|
+
const off = [];
|
|
32
|
+
const on = (target, type, handler, options = { passive: true }) => {
|
|
33
|
+
target.addEventListener(type, handler, options);
|
|
34
|
+
off.push(() => target.removeEventListener(type, handler, options));
|
|
35
|
+
};
|
|
36
|
+
const later = (fn, ms) => {
|
|
37
|
+
const id = setTimeout(fn, ms);
|
|
38
|
+
off.push(() => clearTimeout(id));
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
on,
|
|
42
|
+
later,
|
|
43
|
+
stop: () => {
|
|
44
|
+
for (const f of off.splice(0)) f();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const depthOf = (win, doc) => {
|
|
50
|
+
const height = doc.documentElement.scrollHeight || 0;
|
|
51
|
+
return height > 0 ? (win.scrollY + win.innerHeight) / height : 0;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const typing = (doc) => {
|
|
55
|
+
const el = doc.activeElement;
|
|
56
|
+
return Boolean(
|
|
57
|
+
el &&
|
|
58
|
+
(/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function desktopExit({ on, later }, fire, trigger, win, doc) {
|
|
63
|
+
let armed = false;
|
|
64
|
+
let below = false;
|
|
65
|
+
let lastY = null;
|
|
66
|
+
let dy = 0;
|
|
67
|
+
later(() => {
|
|
68
|
+
armed = true;
|
|
69
|
+
}, trigger.delay_ms ?? EXIT_ARM_MS);
|
|
70
|
+
on(doc, 'mousemove', (e) => {
|
|
71
|
+
if (e.clientY > win.innerHeight * 0.1) below = true;
|
|
72
|
+
if (lastY !== null) dy = e.clientY - lastY;
|
|
73
|
+
lastY = e.clientY;
|
|
74
|
+
});
|
|
75
|
+
on(doc, 'mouseout', (e) => {
|
|
76
|
+
if (
|
|
77
|
+
armed &&
|
|
78
|
+
below &&
|
|
79
|
+
!e.relatedTarget &&
|
|
80
|
+
e.clientY <= 0 &&
|
|
81
|
+
dy < 0 &&
|
|
82
|
+
!typing(doc)
|
|
83
|
+
)
|
|
84
|
+
fire('exit');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mobileExit({ on, later }, fire, trigger, win, doc) {
|
|
89
|
+
let armed = false;
|
|
90
|
+
let reached = 0;
|
|
91
|
+
let lastY = win.scrollY;
|
|
92
|
+
let lastT = Date.now();
|
|
93
|
+
let run = null;
|
|
94
|
+
let hiddenAt = null;
|
|
95
|
+
later(() => {
|
|
96
|
+
armed = true;
|
|
97
|
+
}, trigger.delay_ms ?? EXIT_ARM_MS);
|
|
98
|
+
on(win, 'scroll', () => {
|
|
99
|
+
const y = win.scrollY;
|
|
100
|
+
const t = Date.now();
|
|
101
|
+
reached = Math.max(reached, depthOf(win, doc));
|
|
102
|
+
if (y < lastY) {
|
|
103
|
+
if (!run || t - run.t > FLICK_WINDOW_MS) run = { y: lastY, t: lastT };
|
|
104
|
+
const dist = run.y - y;
|
|
105
|
+
if (
|
|
106
|
+
armed &&
|
|
107
|
+
reached >= EXIT_DEPTH &&
|
|
108
|
+
dist >= FLICK_PX &&
|
|
109
|
+
dist / Math.max(1, t - run.t) >= FLICK_PX_PER_MS
|
|
110
|
+
)
|
|
111
|
+
fire('exit_scroll');
|
|
112
|
+
} else run = null;
|
|
113
|
+
lastY = y;
|
|
114
|
+
lastT = t;
|
|
115
|
+
});
|
|
116
|
+
on(doc, 'visibilitychange', () => {
|
|
117
|
+
if (doc.visibilityState === 'hidden') hiddenAt = Date.now();
|
|
118
|
+
else if (hiddenAt !== null && Date.now() - hiddenAt >= AWAY_MS)
|
|
119
|
+
fire('return');
|
|
120
|
+
if (doc.visibilityState !== 'hidden') hiddenAt = null;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Watch `trigger` and call `fire(reason)` when it fires. Inline and manual forms are opened by the
|
|
126
|
+
* runtime itself, so they watch nothing.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ type: string, delay_ms?: number, depth?: number, seconds?: number }} trigger
|
|
129
|
+
* @param {(reason: string) => void} fire
|
|
130
|
+
* @param {{ device: 'desktop'|'mobile', win?: Window, doc?: Document }} env
|
|
131
|
+
*/
|
|
132
|
+
export function watch(trigger, fire, { device, win = window, doc = document }) {
|
|
133
|
+
const l = listeners();
|
|
134
|
+
const seconds = (trigger.seconds ?? 30) * 1000;
|
|
135
|
+
if (trigger.type === 'entry')
|
|
136
|
+
l.later(() => fire('entry'), trigger.delay_ms ?? 0);
|
|
137
|
+
if (trigger.type === 'time') l.later(() => fire('time'), seconds);
|
|
138
|
+
if (trigger.type === 'exit')
|
|
139
|
+
(device === 'mobile' ? mobileExit : desktopExit)(
|
|
140
|
+
l,
|
|
141
|
+
fire,
|
|
142
|
+
trigger,
|
|
143
|
+
win,
|
|
144
|
+
doc,
|
|
145
|
+
);
|
|
146
|
+
if (trigger.type === 'scroll') {
|
|
147
|
+
const depth = trigger.depth ?? 0.5;
|
|
148
|
+
l.on(win, 'scroll', () => depthOf(win, doc) >= depth && fire('scroll'));
|
|
149
|
+
}
|
|
150
|
+
if (trigger.type === 'idle') {
|
|
151
|
+
let id = null;
|
|
152
|
+
const reset = () => {
|
|
153
|
+
clearTimeout(id);
|
|
154
|
+
id = setTimeout(() => fire('idle'), seconds);
|
|
155
|
+
};
|
|
156
|
+
reset();
|
|
157
|
+
for (const type of INPUTS) l.on(type === 'scroll' ? win : doc, type, reset);
|
|
158
|
+
l.on(doc, 'visibilitychange', reset);
|
|
159
|
+
return () => {
|
|
160
|
+
clearTimeout(id);
|
|
161
|
+
l.stop();
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return l.stop;
|
|
165
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spin-to-win wheel (`./onsite/wheel`), a chunk of its own: an SVG of the form's prize labels
|
|
3
|
+
* that only animates, in the theme's colours: the accent and its tints, a rim of lights, a hub and
|
|
4
|
+
* a pointer. The prize is drawn on the server (`POST /v1/offers/claim`), so the wheel is told where
|
|
5
|
+
* to stop; the segments are equal, and say nothing about the odds. The angle math is ours, after
|
|
6
|
+
* spin-wheel's spinToItem (CrazyTim, MIT).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { restAngle } from './rules.js';
|
|
10
|
+
|
|
11
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
12
|
+
const SPIN_MS = 4500;
|
|
13
|
+
/** A label's room along its slice, in the wheel's units: from beside the hub to inside the rim. */
|
|
14
|
+
const LABEL_FROM = 0.22;
|
|
15
|
+
const LABEL_TO = 0.95;
|
|
16
|
+
/** Its type: as large as fits up to the first, never under the second (about 11px on a 320px wheel). */
|
|
17
|
+
const FONT_MAX = 0.105;
|
|
18
|
+
const FONT_MIN = 0.082;
|
|
19
|
+
/** A bold sans character's width, a little over the average, so a label is never cut. */
|
|
20
|
+
const EM_PER_CHAR = 0.62;
|
|
21
|
+
const LINE_HEIGHT = 1.15;
|
|
22
|
+
const ACCENT = 'var(--hp-accent,#111)';
|
|
23
|
+
const ON_ACCENT = 'var(--hp-on-accent,#fff)';
|
|
24
|
+
const BG = 'var(--hp-bg,#fff)';
|
|
25
|
+
const TEXT = 'var(--hp-text,#111)';
|
|
26
|
+
|
|
27
|
+
function make(tag, attrs = {}) {
|
|
28
|
+
const node = document.createElementNS(NS, tag);
|
|
29
|
+
for (const [key, value] of Object.entries(attrs))
|
|
30
|
+
node.setAttribute(key, String(value));
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Colours as style, not attributes, so the theme's custom properties resolve. */
|
|
35
|
+
function paint(node, fill, stroke, opacity = 1) {
|
|
36
|
+
node.style.fill = fill;
|
|
37
|
+
if (opacity < 1) node.style.fillOpacity = String(opacity);
|
|
38
|
+
if (stroke) node.style.stroke = stroke;
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const reducedMotion = () =>
|
|
43
|
+
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
|
|
44
|
+
|
|
45
|
+
const point = (deg, r = 1) => {
|
|
46
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
47
|
+
return `${(r * Math.cos(rad)).toFixed(4)} ${(r * Math.sin(rad)).toFixed(4)}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How a prize label fits its slice: on one line at the largest size that fits; else on two, broken
|
|
52
|
+
* at the space nearest its middle; else shortened with an ellipsis at the smallest size. A slice's
|
|
53
|
+
* width near the hub bounds the size too, so many slices draw smaller type.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{ lines: string[], size: number }}
|
|
56
|
+
*/
|
|
57
|
+
export function fitLabel(label, slices) {
|
|
58
|
+
const room = LABEL_TO - LABEL_FROM;
|
|
59
|
+
// The slice's width a third of the way out, where a long label's lines run: their height fits.
|
|
60
|
+
const across = 2 * 0.35 * Math.sin(Math.min(Math.PI / slices, Math.PI / 2));
|
|
61
|
+
const top = (lines) =>
|
|
62
|
+
Math.max(FONT_MIN, Math.min(FONT_MAX, across / (lines * LINE_HEIGHT)));
|
|
63
|
+
const sizeFor = (text) => room / (Math.max(1, text.length) * EM_PER_CHAR);
|
|
64
|
+
const one = sizeFor(label);
|
|
65
|
+
if (one >= FONT_MIN) return { lines: [label], size: Math.min(top(1), one) };
|
|
66
|
+
const spaces = [...label.matchAll(/ /g)].map((m) => m.index);
|
|
67
|
+
if (spaces.length) {
|
|
68
|
+
const middle = label.length / 2;
|
|
69
|
+
const at = spaces.reduce((a, b) =>
|
|
70
|
+
Math.abs(b - middle) < Math.abs(a - middle) ? b : a,
|
|
71
|
+
);
|
|
72
|
+
const lines = [label.slice(0, at), label.slice(at + 1)];
|
|
73
|
+
const size = Math.min(top(2), ...lines.map(sizeFor));
|
|
74
|
+
if (size >= FONT_MIN) return { lines, size };
|
|
75
|
+
}
|
|
76
|
+
const fits = Math.floor(room / (FONT_MIN * EM_PER_CHAR));
|
|
77
|
+
return { lines: [`${label.slice(0, fits - 1).trimEnd()}…`], size: FONT_MIN };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Draw the wheel for `prizes` (`[{ key, label }]`) into `container`. Null for fewer than two.
|
|
82
|
+
*
|
|
83
|
+
* @returns {{ spinTo(index: number): Promise<void>, remove(): void } | null}
|
|
84
|
+
*/
|
|
85
|
+
export function mountWheel(container, prizes) {
|
|
86
|
+
const labels = (Array.isArray(prizes) ? prizes : []).map((p) =>
|
|
87
|
+
String(p?.label ?? p?.key ?? ''),
|
|
88
|
+
);
|
|
89
|
+
const n = labels.length;
|
|
90
|
+
if (n < 2) return null;
|
|
91
|
+
const size = 360 / n;
|
|
92
|
+
const svg = make('svg', {
|
|
93
|
+
viewBox: '-1.2 -1.2 2.4 2.4',
|
|
94
|
+
role: 'img',
|
|
95
|
+
'aria-label': `Prize wheel: ${labels.join(', ')}`,
|
|
96
|
+
});
|
|
97
|
+
const wheel = make('g');
|
|
98
|
+
// Spun about its own box, the unit circle round 0 0. A view-box origin sits at the user space's
|
|
99
|
+
// 0 0, not the viewBox's corner, so 50% there is off centre and the wheel swings out of its rim.
|
|
100
|
+
wheel.style.transformBox = 'fill-box';
|
|
101
|
+
wheel.style.transformOrigin = '50% 50%';
|
|
102
|
+
// The accent, a tint of it over the background, and for an odd count a third tone, so no two
|
|
103
|
+
// neighbours match.
|
|
104
|
+
const tones = [
|
|
105
|
+
[1, ON_ACCENT],
|
|
106
|
+
[0.14, TEXT],
|
|
107
|
+
[0.72, ON_ACCENT],
|
|
108
|
+
];
|
|
109
|
+
wheel.append(paint(make('circle', { r: 1 }), BG));
|
|
110
|
+
const lights = [];
|
|
111
|
+
labels.forEach((label, i) => {
|
|
112
|
+
const [tone, ink] = tones[n % 2 === 1 && i === n - 1 ? 2 : i % 2];
|
|
113
|
+
wheel.append(
|
|
114
|
+
paint(
|
|
115
|
+
make('path', {
|
|
116
|
+
d: `M0 0L${point(i * size)}A1 1 0 ${size > 180 ? 1 : 0} 1 ${point((i + 1) * size)}Z`,
|
|
117
|
+
'stroke-width': 0.012,
|
|
118
|
+
}),
|
|
119
|
+
ACCENT,
|
|
120
|
+
BG,
|
|
121
|
+
tone,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
// Along its slice's middle; on the left half turned about its own centre, so at rest no label
|
|
125
|
+
// reads upside down.
|
|
126
|
+
const angle = (i + 0.5) * size - 90;
|
|
127
|
+
const fit = fitLabel(label, n);
|
|
128
|
+
const text = paint(
|
|
129
|
+
make('text', {
|
|
130
|
+
transform: `rotate(${angle}) translate(${(LABEL_FROM + LABEL_TO) / 2} 0)${angle > 90 && angle < 270 ? ' rotate(180)' : ''}`,
|
|
131
|
+
'text-anchor': 'middle',
|
|
132
|
+
'dominant-baseline': 'central',
|
|
133
|
+
'font-size': fit.size.toFixed(4),
|
|
134
|
+
'font-weight': 700,
|
|
135
|
+
'aria-hidden': 'true',
|
|
136
|
+
}),
|
|
137
|
+
ink,
|
|
138
|
+
);
|
|
139
|
+
// Two lines sit either side of the slice's middle.
|
|
140
|
+
fit.lines.forEach((line, k) => {
|
|
141
|
+
const span = make('tspan', {
|
|
142
|
+
x: 0,
|
|
143
|
+
y: ((k - (fit.lines.length - 1) / 2) * fit.size * LINE_HEIGHT).toFixed(
|
|
144
|
+
4,
|
|
145
|
+
),
|
|
146
|
+
});
|
|
147
|
+
span.textContent = line;
|
|
148
|
+
text.append(span);
|
|
149
|
+
});
|
|
150
|
+
wheel.append(text);
|
|
151
|
+
for (const at of [i * size, (i + 0.5) * size]) {
|
|
152
|
+
const [cx, cy] = point(at, 1.075).split(' ');
|
|
153
|
+
lights.push(paint(make('circle', { cx, cy, r: 0.024 }), BG));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
svg.append(
|
|
157
|
+
paint(make('circle', { r: 1.13 }), TEXT),
|
|
158
|
+
...lights,
|
|
159
|
+
wheel,
|
|
160
|
+
paint(make('circle', { r: 0.17, 'stroke-width': 0.035 }), BG, TEXT),
|
|
161
|
+
paint(make('circle', { r: 0.065 }), ACCENT),
|
|
162
|
+
paint(
|
|
163
|
+
make('path', {
|
|
164
|
+
d: 'M-0.12 -1.2L0.12 -1.2L0 -0.86Z',
|
|
165
|
+
'stroke-width': 0.03,
|
|
166
|
+
'stroke-linejoin': 'round',
|
|
167
|
+
}),
|
|
168
|
+
ACCENT,
|
|
169
|
+
BG,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
container.append(svg);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
spinTo(index) {
|
|
176
|
+
const still = reducedMotion();
|
|
177
|
+
wheel.style.transition = still
|
|
178
|
+
? 'none'
|
|
179
|
+
: `transform ${SPIN_MS}ms cubic-bezier(.15,.7,.1,1)`;
|
|
180
|
+
wheel.style.transform = `rotate(${restAngle(index, labels.length, still ? 0 : 5)}deg)`;
|
|
181
|
+
if (still) return Promise.resolve();
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const done = setTimeout(resolve, SPIN_MS + 200);
|
|
184
|
+
wheel.addEventListener(
|
|
185
|
+
'transitionend',
|
|
186
|
+
() => {
|
|
187
|
+
clearTimeout(done);
|
|
188
|
+
resolve();
|
|
189
|
+
},
|
|
190
|
+
{ once: true },
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
remove: () => svg.remove(),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hp.onsite`: open (`show`) a form by hand, listen to the forms, keep the screen clear, or read why
|
|
3
|
+
* each form did or did not show, all callable before the onsite runtime has loaded. Listeners and
|
|
4
|
+
* suppressions wait on the page for the runtime. An `open` made before it started waits for it, and resolves with what
|
|
5
|
+
* the runtime did, or `false` once the page knows no runtime will start; nothing here throws.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CONTROLLER = Symbol.for('@thg-agentic/hyper-personalisation-web.onsite');
|
|
9
|
+
const PENDING = Symbol.for(
|
|
10
|
+
'@thg-agentic/hyper-personalisation-web.onsite.pending',
|
|
11
|
+
);
|
|
12
|
+
const LISTENERS = Symbol.for(
|
|
13
|
+
'@thg-agentic/hyper-personalisation-web.onsite.listeners',
|
|
14
|
+
);
|
|
15
|
+
const SUPPRESSED = Symbol.for(
|
|
16
|
+
'@thg-agentic/hyper-personalisation-web.onsite.suppressed',
|
|
17
|
+
);
|
|
18
|
+
const EVENTS = ['shown', 'submitted', 'apply', 'closed', 'held'];
|
|
19
|
+
const MAX_PENDING = 10;
|
|
20
|
+
|
|
21
|
+
const page = () => (typeof window === 'undefined' ? null : window);
|
|
22
|
+
|
|
23
|
+
/** No onsite runtime will start on this page: every `open` waiting for one resolves `false`. */
|
|
24
|
+
export function dropPendingOpens() {
|
|
25
|
+
try {
|
|
26
|
+
for (const { resolve } of page()?.[PENDING]?.splice(0) ?? [])
|
|
27
|
+
resolve(false);
|
|
28
|
+
} catch {
|
|
29
|
+
/* never into the page */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const onsite = Object.freeze({
|
|
34
|
+
/** Show the form on `slot` now, if its rules allow it. Resolves to whether it was shown. */
|
|
35
|
+
open(slot) {
|
|
36
|
+
try {
|
|
37
|
+
const w = page();
|
|
38
|
+
if (!w || typeof slot !== 'string') return Promise.resolve(false);
|
|
39
|
+
if (w[CONTROLLER]) return w[CONTROLLER].open(slot);
|
|
40
|
+
w[PENDING] ??= [];
|
|
41
|
+
if (w[PENDING].length < MAX_PENDING)
|
|
42
|
+
return new Promise((resolve) => w[PENDING].push({ slot, resolve }));
|
|
43
|
+
} catch {
|
|
44
|
+
/* never into the page */
|
|
45
|
+
}
|
|
46
|
+
return Promise.resolve(false);
|
|
47
|
+
},
|
|
48
|
+
/** The same as `open`, by the name the console's snippets use. */
|
|
49
|
+
show(slot) {
|
|
50
|
+
return onsite.open(slot);
|
|
51
|
+
},
|
|
52
|
+
/**
|
|
53
|
+
* Listen to the forms: `shown`, `submitted` (`{ coupon }`: the claimed code for a form that
|
|
54
|
+
* spins, else null), `apply` (`{ coupon }`: a reminder's code to apply), `closed`
|
|
55
|
+
* (`{ reason }`) and `held` (a held-out visitor's trigger fired: the site shows what it shows
|
|
56
|
+
* without HP). Each detail carries the `slot`, `arm` and `format`. Returns a function that
|
|
57
|
+
* stops listening.
|
|
58
|
+
*/
|
|
59
|
+
on(name, fn) {
|
|
60
|
+
try {
|
|
61
|
+
const w = page();
|
|
62
|
+
if (!w || !EVENTS.includes(name) || typeof fn !== 'function')
|
|
63
|
+
return () => {};
|
|
64
|
+
w[LISTENERS] ??= {};
|
|
65
|
+
(w[LISTENERS][name] ??= new Set()).add(fn);
|
|
66
|
+
return () => w[LISTENERS][name].delete(fn);
|
|
67
|
+
} catch {
|
|
68
|
+
return () => {};
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
/** Keep the screen clear, for `reason`: a form showing goes, quietly, and none shows until every reason is lifted. */
|
|
72
|
+
suppress(reason = 'page') {
|
|
73
|
+
try {
|
|
74
|
+
const w = page();
|
|
75
|
+
if (!w) return;
|
|
76
|
+
(w[SUPPRESSED] ??= new Set()).add(String(reason));
|
|
77
|
+
w[CONTROLLER]?.suppress();
|
|
78
|
+
} catch {
|
|
79
|
+
/* never into the page */
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
/** Lift `reason`, or every reason when none is given. */
|
|
83
|
+
unsuppress(reason) {
|
|
84
|
+
try {
|
|
85
|
+
const set = page()?.[SUPPRESSED];
|
|
86
|
+
if (reason === undefined) set?.clear();
|
|
87
|
+
else set?.delete(String(reason));
|
|
88
|
+
} catch {
|
|
89
|
+
/* never into the page */
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
/**
|
|
93
|
+
* Each form's state, as `debug`, once the page knows whether onsite forms run on it: when the
|
|
94
|
+
* runtime has started, or `[]` once none will (not opted in, no live form, the chunk failed).
|
|
95
|
+
*/
|
|
96
|
+
ready() {
|
|
97
|
+
try {
|
|
98
|
+
const w = page();
|
|
99
|
+
if (!w) return Promise.resolve([]);
|
|
100
|
+
if (w[CONTROLLER]) return Promise.resolve(onsite.debug());
|
|
101
|
+
w[PENDING] ??= [];
|
|
102
|
+
if (w[PENDING].length < MAX_PENDING)
|
|
103
|
+
return new Promise((resolve) =>
|
|
104
|
+
// Settled when the runtime starts (it opens no form for no slot) or none will.
|
|
105
|
+
w[PENDING].push({
|
|
106
|
+
slot: null,
|
|
107
|
+
resolve: () => resolve(onsite.debug()),
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
/* never into the page */
|
|
112
|
+
}
|
|
113
|
+
return Promise.resolve([]);
|
|
114
|
+
},
|
|
115
|
+
/** Each form's eligibility on this page, and why: `[]` until the runtime has started. */
|
|
116
|
+
debug() {
|
|
117
|
+
try {
|
|
118
|
+
return page()?.[CONTROLLER]?.debug() ?? [];
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
package/src/settings.js
CHANGED
|
@@ -156,6 +156,22 @@ function readAnonymousId(raw) {
|
|
|
156
156
|
: null;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The live onsite forms (`hp.onsite`), or null when there are none. Only the shape is checked here:
|
|
161
|
+
* ./onsite reads each form itself, so the core never grows with the forms' rules.
|
|
162
|
+
*/
|
|
163
|
+
function readOnsite(raw) {
|
|
164
|
+
const forms = Array.isArray(raw?.forms) ? raw.forms.filter(isObject) : [];
|
|
165
|
+
if (forms.length === 0) return null;
|
|
166
|
+
return {
|
|
167
|
+
forms,
|
|
168
|
+
capture: isObject(raw.capture) ? raw.capture : null,
|
|
169
|
+
...(Number.isInteger(raw.overlay_gap_minutes)
|
|
170
|
+
? { overlay_gap_minutes: raw.overlay_gap_minutes }
|
|
171
|
+
: {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
/**
|
|
160
176
|
* Whether a 2xx body is a settings answer at all: an `hp` block saying on or off. Anything else (a
|
|
161
177
|
* captive portal's page, a proxy's error) is no answer, so the last good settings and what was
|
|
@@ -174,6 +190,7 @@ export function isSettingsAnswer(body) {
|
|
|
174
190
|
export function readSettings(body) {
|
|
175
191
|
const hp = isObject(body) ? body.hp : null;
|
|
176
192
|
if (!isObject(hp) || hp.enabled !== true) return DISABLED;
|
|
193
|
+
const onsite = readOnsite(hp.onsite);
|
|
177
194
|
return {
|
|
178
195
|
enabled: true,
|
|
179
196
|
propertyId:
|
|
@@ -192,6 +209,7 @@ export function readSettings(body) {
|
|
|
192
209
|
// The share of visitors sent for (3.18). Anything that is not a share sends for everyone.
|
|
193
210
|
sampleRate: isFraction(hp.sampleRate) ? hp.sampleRate : 1,
|
|
194
211
|
debug: readDebug(hp.debug),
|
|
212
|
+
...(onsite && { onsite }),
|
|
195
213
|
plan:
|
|
196
214
|
isObject(body.plan) && isObject(body.plan.track)
|
|
197
215
|
? { track: body.plan.track }
|
package/src/spec-version.js
CHANGED
package/src/storage-scope.js
CHANGED
|
@@ -59,6 +59,11 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
|
|
|
59
59
|
* (`?hp_debug=`), checked against the settings on every page (3.18).
|
|
60
60
|
*/
|
|
61
61
|
debug: 'thg_hp_debug',
|
|
62
|
+
/**
|
|
63
|
+
* localStorage and sessionStorage, per property: onsite forms' opaque counters per slot (shown,
|
|
64
|
+
* last closed, submitted) and a subscribed flag. Never contact data (onsite).
|
|
65
|
+
*/
|
|
66
|
+
onsite: 'thg_hp_onsite',
|
|
62
67
|
});
|
|
63
68
|
|
|
64
69
|
/**
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.24.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/types/bootstrap.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
PageType,
|
|
20
20
|
StorageNames,
|
|
21
21
|
} from './index';
|
|
22
|
+
import type { OnsiteFacade, OnsiteOptions, OnsiteSettings } from './onsite';
|
|
22
23
|
|
|
23
24
|
/** Everything the page itself configures: the rest is the tenant's, and comes from settings. */
|
|
24
25
|
export interface HpConfig {
|
|
@@ -68,6 +69,8 @@ export interface SettingsResponse {
|
|
|
68
69
|
string,
|
|
69
70
|
{ share: number; salt: string; readoutSlots?: string[] }
|
|
70
71
|
>;
|
|
72
|
+
/** The live onsite forms (ADR 0064). Started only for a page that passes `onsite`. */
|
|
73
|
+
onsite?: OnsiteSettings;
|
|
71
74
|
};
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -148,6 +151,17 @@ export interface BootstrapOptions {
|
|
|
148
151
|
settings?: SettingsResponse;
|
|
149
152
|
/** createAnalytics options that are functions, e.g. getRecommendations or getExtensions. */
|
|
150
153
|
extend?: Partial<AnalyticsOptions>;
|
|
154
|
+
/**
|
|
155
|
+
* Onsite forms: `true` (or the options) loads `./onsite` as a separate chunk when the settings
|
|
156
|
+
* have a live form, and starts it. Absent or false: never loaded.
|
|
157
|
+
*/
|
|
158
|
+
onsite?: boolean | OnsiteOptions;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The client bootstrap resolves to: the lower-level client, plus `onsite`. */
|
|
162
|
+
export interface BootstrappedClient extends AnalyticsClient {
|
|
163
|
+
/** Open a form by hand, or read why each form did or did not show (`[]` before it started). */
|
|
164
|
+
onsite: OnsiteFacade;
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
/**
|
|
@@ -157,7 +171,7 @@ export interface BootstrapOptions {
|
|
|
157
171
|
export declare function bootstrap(
|
|
158
172
|
config: HpConfig,
|
|
159
173
|
options: BootstrapOptions,
|
|
160
|
-
): Promise<
|
|
174
|
+
): Promise<BootstrappedClient | null>;
|
|
161
175
|
/** Every reason the config and options cannot be used; empty when they can. */
|
|
162
176
|
export declare function validateConfig(
|
|
163
177
|
config: unknown,
|