@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.
@@ -20,7 +20,11 @@ export const TRIGGERS = [
20
20
  'inline',
21
21
  'manual',
22
22
  ];
23
- /** The formats that cover the page: one of them per page view, the highest priority first. */
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');
@@ -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:-20px;right:-20px;border:8px solid transparent;background:var(--t) padding-box;color:var(--b)}
193
- .teaser.p-right .x{right:auto;left:-20px}
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}
@@ -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
- const MAX_LABEL = 16;
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(0.6 0)${angle > 90 && angle < 270 ? ' rotate(180)' : ''}`,
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': n > 6 ? 0.09 : 0.105,
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
- text.textContent =
101
- label.length > MAX_LABEL ? `${label.slice(0, MAX_LABEL - 1)}…` : label;
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(' ');
@@ -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) and `closed`
55
- * (`{ reason }`). Each detail carries the `slot`, `arm` and `format`. Returns a function 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
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.23.0';
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