@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/rules.js
CHANGED
|
@@ -20,7 +20,11 @@ export const TRIGGERS = [
|
|
|
20
20
|
'inline',
|
|
21
21
|
'manual',
|
|
22
22
|
];
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The formats that cover the page: one of them per page view, the highest priority first. Once one
|
|
25
|
+
* has shown (or the visitor was held out of it) the page view's allowance is spent, so closing it
|
|
26
|
+
* never lets the next waiting form in.
|
|
27
|
+
*/
|
|
24
28
|
export const OVERLAY = (format) => format !== 'inline';
|
|
25
29
|
/**
|
|
26
30
|
* The formats that interrupt the shopper, which the property's gap spaces out: not a bar or a
|
|
@@ -49,6 +53,7 @@ export function readForm(raw) {
|
|
|
49
53
|
priority: Number.isFinite(raw.priority) ? raw.priority : 0,
|
|
50
54
|
pages: isObject(raw.pages) ? raw.pages : {},
|
|
51
55
|
devices: strings(raw.devices),
|
|
56
|
+
aliases: strings(raw.aliases),
|
|
52
57
|
audiences: strings(raw.audiences),
|
|
53
58
|
caps: {
|
|
54
59
|
per_session: count(caps.per_session),
|
|
@@ -115,6 +120,14 @@ export function deviceOf(win) {
|
|
|
115
120
|
|
|
116
121
|
const at = (iso) => (typeof iso === 'string' ? Date.parse(iso) : Number.NaN);
|
|
117
122
|
|
|
123
|
+
/**
|
|
124
|
+
* A form the site opens itself (`manual`: its own sign-up button) is the shopper asking for it, so
|
|
125
|
+
* how often it has shown does not hold it back: no caps, gap or page view allowance, as Klaviyo's
|
|
126
|
+
* custom-triggered forms ignore their frequency settings. Its pages, devices, dates and audiences
|
|
127
|
+
* still do.
|
|
128
|
+
*/
|
|
129
|
+
export const ASKED = (form) => form.trigger.type === 'manual';
|
|
130
|
+
|
|
118
131
|
/**
|
|
119
132
|
* Every reason `form` may not show now, empty when it may. `env` is the page:
|
|
120
133
|
* `{ path, device, now, seen, subscribed, personalisation, preview, audiences }`, where `seen` is
|
|
@@ -137,8 +150,8 @@ export function blockers(form, env) {
|
|
|
137
150
|
)
|
|
138
151
|
reasons.push('not in its audiences');
|
|
139
152
|
}
|
|
140
|
-
// A trader previewing a form sees it whatever the counters say.
|
|
141
|
-
if (env.preview) return reasons;
|
|
153
|
+
// A trader previewing a form, or a shopper who asked for it, sees it whatever the counters say.
|
|
154
|
+
if (env.preview || env.asked || ASKED(form)) return reasons;
|
|
142
155
|
const { seen } = env;
|
|
143
156
|
const { caps } = form;
|
|
144
157
|
if (seen.submitted) reasons.push('already submitted');
|
package/src/onsite/styles.js
CHANGED
|
@@ -66,7 +66,9 @@ dialog::backdrop{background:none}
|
|
|
66
66
|
.io{display:block;width:100%;padding:0;border:0;background:none;cursor:pointer;color:inherit}
|
|
67
67
|
.f{display:flex;flex-direction:column;gap:14px;padding:44px 40px 36px;flex:1 0 auto;min-width:0}
|
|
68
68
|
.t-split .f{flex:1 1 0;justify-content:center}
|
|
69
|
+
.lk{min-width:var(--lw);min-height:var(--lh)}
|
|
69
70
|
.lk>.f{justify-content:center}
|
|
71
|
+
.an{align-self:start;margin-top:var(--tp,0px);max-height:calc(100% - var(--tp,0px))}
|
|
70
72
|
.z-full .f,.fullscreen .f{justify-content:center;width:min(100%,560px);margin:auto}
|
|
71
73
|
.pd-none{padding:0}
|
|
72
74
|
.pd-sm{padding:24px}
|
|
@@ -138,7 +140,7 @@ legend{padding:0;margin-bottom:8px;font-size:13px;font-weight:600}
|
|
|
138
140
|
.bi{display:block;width:100%;height:auto}
|
|
139
141
|
.bl{display:block;border-radius:var(--br)}
|
|
140
142
|
hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--ln)}
|
|
141
|
-
.wh{width:min(100%,320px);align-self:center}
|
|
143
|
+
.wh{width:min(100%,320px);aspect-ratio:1;align-self:center}
|
|
142
144
|
.wh svg{display:block;width:100%;height:auto;filter:drop-shadow(0 16px 24px rgb(0 0 0/.2))}
|
|
143
145
|
.hd{display:none}
|
|
144
146
|
.uc h2{text-transform:uppercase;font-weight:800;letter-spacing:-.01em;line-height:1.02}
|
|
@@ -189,8 +191,10 @@ hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--ln)}
|
|
|
189
191
|
.p-left .tb,.p-right .tb{writing-mode:vertical-rl;padding:22px 13px;border-radius:var(--br) 0 0 var(--br)}
|
|
190
192
|
.p-left .tb{transform:rotate(180deg)}
|
|
191
193
|
.b-pill.teaser .tb{border-radius:999px}
|
|
192
|
-
.teaser .x{top
|
|
193
|
-
.teaser.
|
|
194
|
+
.teaser .x{top:auto;right:0;bottom:100%;border:10px solid transparent;background:var(--t) padding-box;box-shadow:none;color:var(--b)}
|
|
195
|
+
.teaser .x::before,.teaser .x::after{width:10px}
|
|
196
|
+
.teaser.p-left .x,.teaser.p-right .x{bottom:auto;top:100%}
|
|
197
|
+
.teaser.p-left .x{right:auto;left:0}
|
|
194
198
|
.inline{position:static;display:block;height:100%}
|
|
195
199
|
.inline .w{width:100%;height:100%;box-shadow:none;border:1px solid var(--ln);animation:none}
|
|
196
200
|
.inline .t-stack .m{max-height:none}
|
package/src/onsite/wheel.js
CHANGED
|
@@ -10,7 +10,15 @@ import { restAngle } from './rules.js';
|
|
|
10
10
|
|
|
11
11
|
const NS = 'http://www.w3.org/2000/svg';
|
|
12
12
|
const SPIN_MS = 4500;
|
|
13
|
-
|
|
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;
|
|
14
22
|
const ACCENT = 'var(--hp-accent,#111)';
|
|
15
23
|
const ON_ACCENT = 'var(--hp-on-accent,#fff)';
|
|
16
24
|
const BG = 'var(--hp-bg,#fff)';
|
|
@@ -39,6 +47,36 @@ const point = (deg, r = 1) => {
|
|
|
39
47
|
return `${(r * Math.cos(rad)).toFixed(4)} ${(r * Math.sin(rad)).toFixed(4)}`;
|
|
40
48
|
};
|
|
41
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
|
+
|
|
42
80
|
/**
|
|
43
81
|
* Draw the wheel for `prizes` (`[{ key, label }]`) into `container`. Null for fewer than two.
|
|
44
82
|
*
|
|
@@ -86,19 +124,29 @@ export function mountWheel(container, prizes) {
|
|
|
86
124
|
// Along its slice's middle; on the left half turned about its own centre, so at rest no label
|
|
87
125
|
// reads upside down.
|
|
88
126
|
const angle = (i + 0.5) * size - 90;
|
|
127
|
+
const fit = fitLabel(label, n);
|
|
89
128
|
const text = paint(
|
|
90
129
|
make('text', {
|
|
91
|
-
transform: `rotate(${angle}) translate(
|
|
130
|
+
transform: `rotate(${angle}) translate(${(LABEL_FROM + LABEL_TO) / 2} 0)${angle > 90 && angle < 270 ? ' rotate(180)' : ''}`,
|
|
92
131
|
'text-anchor': 'middle',
|
|
93
132
|
'dominant-baseline': 'central',
|
|
94
|
-
'font-size':
|
|
133
|
+
'font-size': fit.size.toFixed(4),
|
|
95
134
|
'font-weight': 700,
|
|
96
135
|
'aria-hidden': 'true',
|
|
97
136
|
}),
|
|
98
137
|
ink,
|
|
99
138
|
);
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
});
|
|
102
150
|
wheel.append(text);
|
|
103
151
|
for (const at of [i * size, (i + 0.5) * size]) {
|
|
104
152
|
const [cx, cy] = point(at, 1.075).split(' ');
|
package/src/onsite-facade.js
CHANGED
|
@@ -15,7 +15,7 @@ const LISTENERS = Symbol.for(
|
|
|
15
15
|
const SUPPRESSED = Symbol.for(
|
|
16
16
|
'@thg-agentic/hyper-personalisation-web.onsite.suppressed',
|
|
17
17
|
);
|
|
18
|
-
const EVENTS = ['shown', 'submitted', 'apply', 'closed'];
|
|
18
|
+
const EVENTS = ['shown', 'submitted', 'apply', 'closed', 'held'];
|
|
19
19
|
const MAX_PENDING = 10;
|
|
20
20
|
|
|
21
21
|
const page = () => (typeof window === 'undefined' ? null : window);
|
|
@@ -51,8 +51,9 @@ export const onsite = Object.freeze({
|
|
|
51
51
|
},
|
|
52
52
|
/**
|
|
53
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)
|
|
55
|
-
* (`{ reason }`)
|
|
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
|
|
56
57
|
* stops listening.
|
|
57
58
|
*/
|
|
58
59
|
on(name, fn) {
|
|
@@ -88,6 +89,29 @@ export const onsite = Object.freeze({
|
|
|
88
89
|
/* never into the page */
|
|
89
90
|
}
|
|
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
|
+
},
|
|
91
115
|
/** Each form's eligibility on this page, and why: `[]` until the runtime has started. */
|
|
92
116
|
debug() {
|
|
93
117
|
try {
|
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/onsite.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface OnsiteForm {
|
|
|
42
42
|
selector?: string;
|
|
43
43
|
};
|
|
44
44
|
/** Path globs, `*` matching anything. No `include` is every page; an exclusion wins. */
|
|
45
|
+
/** Other names `show()` opens this form by (`footer_signup` for the site's sign-up button). */
|
|
46
|
+
aliases?: string[];
|
|
45
47
|
pages?: { include?: string[]; exclude?: string[] };
|
|
46
48
|
/** `mobile` is a coarse pointer or a viewport under 768px. Absent: both. */
|
|
47
49
|
devices?: Array<'desktop' | 'mobile'>;
|
|
@@ -72,6 +74,8 @@ export interface OnsiteForm {
|
|
|
72
74
|
}>;
|
|
73
75
|
/** The wheel's labels only: odds and codes are the server's. */
|
|
74
76
|
prizes?: Array<{ key: string; label: string }>;
|
|
77
|
+
/** A destination is handed the shopper's code, so the brand's email can carry it. */
|
|
78
|
+
code_relayed?: true;
|
|
75
79
|
channels?: Array<'email' | 'sms'>;
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -166,6 +170,8 @@ export interface OnsiteEvents {
|
|
|
166
170
|
/** A reminder's apply button: put the code on the basket. */
|
|
167
171
|
apply: OnsiteEventDetail & { coupon: OnsiteCoupon | null };
|
|
168
172
|
closed: OnsiteEventDetail & { reason: string };
|
|
173
|
+
/** A held-out visitor's trigger fired (not a form the site opened, whose `open` resolves false): show what the site shows without HP. */
|
|
174
|
+
held: OnsiteEventDetail;
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
/** `hp.onsite`, callable before the runtime has loaded. */
|
|
@@ -182,6 +188,8 @@ export interface OnsiteFacade {
|
|
|
182
188
|
suppress(reason?: string): void;
|
|
183
189
|
/** Lift `reason`, or every reason. */
|
|
184
190
|
unsuppress(reason?: string): void;
|
|
191
|
+
/** Each form's state once the page knows whether onsite forms run on it; `[]` when none will. */
|
|
192
|
+
ready(): Promise<OnsiteTrace[]>;
|
|
185
193
|
debug(): OnsiteTrace[];
|
|
186
194
|
}
|
|
187
195
|
|