@thg-agentic/hyper-personalisation-web 3.27.0 → 3.28.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.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * An onsite form's products block (ADR 0064 as amended 2026-11-02): the products the server filled
3
+ * it with (`served`), as cards that each link to the product, in a row that scrolls sideways or a
4
+ * grid. A chunk of its own, loaded only for a form that shows one.
5
+ *
6
+ * Reported as a rail is (../auto/rails.js): Recommendations Viewed once a card meets the viewability
7
+ * standard (or before the first click), and Product Clicked with the block's
8
+ * `recommendation_request_id`, which the form's Slot Engaged also records. A preview draws what the
9
+ * console filled it with, whatever the block's `min`, never navigates and reports nothing.
10
+ */
11
+
12
+ import { watchViewability } from '../auto/viewability.js';
13
+ import { httpsHref } from './rich-text.js';
14
+
15
+ const CSS = `.pr ul{list-style:none;margin:0;padding:0;display:grid;gap:12px}
16
+ .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
+ .pr-c li{scroll-snap-align:start}
18
+ .pr-g ul{grid-template-columns:repeat(auto-fill,minmax(132px,1fr))}
19
+ .mob .pr-g ul{grid-template-columns:repeat(2,1fr)}
20
+ .pr.a-center ul{justify-content:center}
21
+ .pa{display:flex;flex-direction:column;gap:4px;height:100%;color:inherit;text-decoration:none;border-radius:var(--ir);outline-offset:2px}
22
+ .pa:focus-visible{outline:2px solid var(--a)}
23
+ .pi{display:block;width:100%;aspect-ratio:1;object-fit:cover;border-radius:var(--ir);background:color-mix(in srgb,var(--t) 7%,var(--b))}
24
+ .pn{font-size:14px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
25
+ .pp{display:flex;gap:6px;align-items:baseline;font-size:14px;font-weight:700}
26
+ .pp s{font-weight:400;color:var(--mu)}
27
+ .pb{margin-top:auto;padding:8px 12px;font-size:14px}
28
+ @media (prefers-reduced-motion:reduce){.pr-c ul{scroll-behavior:auto}}`;
29
+
30
+ const ALIGNS = ['left', 'center', 'right'];
31
+ const isObject = (v) =>
32
+ v !== null && typeof v === 'object' && !Array.isArray(v);
33
+
34
+ /** Money in the form's language; the bare amount for a currency the browser does not know. */
35
+ function moneyIn(lang, currency) {
36
+ try {
37
+ const format = new Intl.NumberFormat(lang || undefined, {
38
+ style: 'currency',
39
+ currency,
40
+ });
41
+ return (n) => format.format(n);
42
+ } catch {
43
+ return (n) => n.toFixed(2);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Draw `block`'s served products into `box`, in the form's shadow `root`.
49
+ *
50
+ * @param {HTMLElement} box The block's node, already on the step.
51
+ * @param {object} block The products block, with the server's `served`.
52
+ * @param {{ el: Function, lang?: string, root: ShadowRoot, placement: string, preview: boolean,
53
+ * client: object|null, engaged: Function, seen: Set<string> }} o `client` is null when nothing
54
+ * is reported; `seen` holds the form's rails already reported seen, so a step shown again is not.
55
+ * @returns {(() => void)|undefined} Stops watching the cards, for when the step changes.
56
+ */
57
+ export function mountProducts(box, block, o) {
58
+ const { el } = o;
59
+ const served = isObject(block.served) ? block.served : {};
60
+ const limit = Number.isInteger(block.limit)
61
+ ? Math.min(Math.max(block.limit, 1), 12)
62
+ : 4;
63
+ const shown = (Array.isArray(served.products) ? served.products : [])
64
+ .filter(
65
+ (p) =>
66
+ isObject(p) && typeof p.product_id === 'string' && httpsHref(p.url),
67
+ )
68
+ .slice(0, limit);
69
+ if (shown.length === 0) return;
70
+ const show = isObject(block.show) ? block.show : {};
71
+ const grid = block.layout === 'grid';
72
+ box.classList.add(grid ? 'pr-g' : 'pr-c');
73
+ if (ALIGNS.includes(block.align)) box.classList.add(`a-${block.align}`);
74
+ // A row that scrolls is named as a region; each card is a link, so Tab walks it into view.
75
+ if (!grid) {
76
+ box.setAttribute('role', 'region');
77
+ box.setAttribute('aria-label', 'Products');
78
+ }
79
+ const style = el('style');
80
+ style.textContent = CSS;
81
+ const list = el('ul');
82
+ list.setAttribute('role', 'list');
83
+
84
+ const tile = (p, position) => ({
85
+ product_id: p.product_id,
86
+ ...(typeof p.name === 'string' ? { name: p.name } : {}),
87
+ ...(Number.isFinite(p.price) ? { price: p.price } : {}),
88
+ position,
89
+ });
90
+ const client =
91
+ typeof served.recommendation_request_id === 'string' && !o.preview
92
+ ? o.client
93
+ : null;
94
+ const viewable = new Map();
95
+ const id = served.recommendation_request_id;
96
+ const seen = o.seen ?? new Set();
97
+ let pending = null;
98
+ const impression = () => {
99
+ clearTimeout(pending);
100
+ if (!client || seen.has(id)) return;
101
+ seen.add(id);
102
+ client.recommendationsViewed({
103
+ recommendation_request_id: id,
104
+ placement: served.placement || o.placement,
105
+ // Required outside a holdout: a list that is the shopper's own (their basket) has no source.
106
+ strategy: served.strategy || served.recipe || block.source,
107
+ recipe: served.recipe ?? null,
108
+ algorithm_version: served.algorithm_version ?? null,
109
+ products: shown.map((p, i) => tile(p, i + 1)),
110
+ viewable: [...viewable.values()].sort((a, b) => a.position - b.position),
111
+ });
112
+ };
113
+
114
+ for (const [i, p] of shown.entries()) {
115
+ const position = i + 1;
116
+ const item = el('li', 'pc');
117
+ item.dataset.hpProduct = p.product_id;
118
+ item.dataset.hpPosition = String(position);
119
+ const a = el('a', 'pa');
120
+ const name = typeof p.name === 'string' ? p.name : '';
121
+ const image = httpsHref(p.image);
122
+ if (image) {
123
+ const img = el('img', 'pi');
124
+ // The card's name says what it is; with the name hidden, the picture says it.
125
+ img.alt = show.name === false ? name : '';
126
+ img.loading = 'lazy';
127
+ img.decoding = 'async';
128
+ img.src = image;
129
+ a.append(img);
130
+ } else a.append(el('span', 'pi'));
131
+ if (show.name !== false && name) a.append(el('span', 'pn', name));
132
+ if (show.price !== false && Number.isFinite(p.price)) {
133
+ const money = moneyIn(o.lang, p.currency);
134
+ const price = el('span', 'pp');
135
+ price.append(el('span', '', money(p.price)));
136
+ if (Number.isFinite(p.compare_at_price) && p.compare_at_price > p.price) {
137
+ const was = el('s', '', money(p.compare_at_price));
138
+ was.setAttribute('aria-label', `was ${money(p.compare_at_price)}`);
139
+ price.append(was);
140
+ }
141
+ a.append(price);
142
+ }
143
+ if (show.name === false && !image)
144
+ a.setAttribute('aria-label', name || p.product_id);
145
+ if (typeof block.button === 'string' && block.button)
146
+ a.append(el('span', 'btn pb', block.button));
147
+ a.href = httpsHref(p.url);
148
+ a.rel = 'noopener';
149
+ a.addEventListener('click', (e) => {
150
+ if (o.preview) return e.preventDefault();
151
+ // The impression first, so the click joins it, as a rail's does.
152
+ impression();
153
+ client?.productClicked({
154
+ recommendation_request_id: served.recommendation_request_id,
155
+ interaction: 'navigate',
156
+ products: [tile(p, position)],
157
+ });
158
+ o.engaged('click', undefined, {
159
+ position,
160
+ products: [tile(p, position)],
161
+ });
162
+ });
163
+ item.append(a);
164
+ list.append(item);
165
+ }
166
+ box.append(style, list);
167
+ if (!client || seen.has(id)) return;
168
+ return watchViewability({
169
+ selector: '[data-hp-product]',
170
+ root: o.root,
171
+ onViewable(node) {
172
+ const position = Number(node.dataset.hpPosition);
173
+ if (!viewable.has(position))
174
+ viewable.set(position, {
175
+ position,
176
+ product_id: node.dataset.hpProduct,
177
+ viewable_at: new Date().toISOString(),
178
+ });
179
+ // Deferred a turn, so cards that became viewable together land on one event.
180
+ pending ??= setTimeout(impression, 0);
181
+ return true;
182
+ },
183
+ });
184
+ }
@@ -213,6 +213,9 @@ export function warm(form, creative, id, seen = new Set()) {
213
213
  );
214
214
  }
215
215
 
216
+ /** A timer the step set, or what a products block watches (a teardown). */
217
+ const stopTimer = (t) => (t?.call ? t() : clearInterval(t));
218
+
216
219
  let ids = 0;
217
220
  const uid = (kind) => `hp-${kind}-${++ids}`;
218
221
 
@@ -315,6 +318,8 @@ function styles(root) {
315
318
  * @param {ReturnType<import('./api.js').createApi>} o.api
316
319
  * @param {ReturnType<import('./counters.js').createCounters>} o.counters
317
320
  * @param {() => Promise<{ mountWheel: Function }>} o.loadWheel
321
+ * @param {() => Promise<{ mountProducts: Function }>} [o.loadProducts] A products block's cards
322
+ * (./products.js), loaded when a step shows one.
318
323
  * @param {() => Promise<{ answerFields: Function, answerOf: Function }>} o.loadAnswers The fields of
319
324
  * a date or number question (./answers.js), loaded once as the form mounts when it asks one.
320
325
  * @param {Element|null} [o.container] The page's element, for an inline form.
@@ -393,6 +398,8 @@ export function mountForm(o) {
393
398
  mobile: preview?.device === 'mobile',
394
399
  answers: {},
395
400
  sent: new Set(),
401
+ // The products blocks' rails reported seen, by id: a step shown again does not report it twice.
402
+ seen: new Set(),
396
403
  receipt: null,
397
404
  claim: o.coupon
398
405
  ? { code: o.coupon }
@@ -444,7 +451,7 @@ export function mountForm(o) {
444
451
  small.addEventListener?.('change', resize);
445
452
  }
446
453
 
447
- const engaged = (engagement, step) => {
454
+ const engaged = (engagement, step, item = {}) => {
448
455
  if (preview || !decision.decision_id || !access.analytics()) return;
449
456
  client.slotEngaged({
450
457
  decision_id: decision.decision_id,
@@ -452,6 +459,7 @@ export function mountForm(o) {
452
459
  arm: decision.arm ?? null,
453
460
  engagement,
454
461
  step: step ?? state.step?.id ?? null,
462
+ ...item,
455
463
  });
456
464
  };
457
465
 
@@ -798,6 +806,32 @@ export function mountForm(o) {
798
806
  return box;
799
807
  }
800
808
 
809
+ /**
810
+ * The products the server filled the block with (`served`), drawn by ./products.js, a chunk of its
811
+ * own loaded only for a form that shows them. A preview draws what the console filled it with.
812
+ */
813
+ function products(block) {
814
+ const box = el('div', 'pr');
815
+ o.loadProducts?.()
816
+ .then((m) => {
817
+ if (box.isConnected)
818
+ state.timers.push(
819
+ m.mountProducts(box, block, {
820
+ el,
821
+ lang,
822
+ root,
823
+ placement: form.slot,
824
+ preview,
825
+ client: !preview && access.analytics() && client,
826
+ engaged,
827
+ seen: state.seen,
828
+ }),
829
+ );
830
+ })
831
+ .catch(() => {});
832
+ return box;
833
+ }
834
+
801
835
  const BLOCKS = {
802
836
  heading(block) {
803
837
  const h = typeset(el('h2', '', block.text), block, 'xl');
@@ -857,6 +891,7 @@ export function mountForm(o) {
857
891
  },
858
892
  countdown,
859
893
  progress,
894
+ products,
860
895
  image: picture,
861
896
  video: picture,
862
897
  spacer(block) {
@@ -951,7 +986,7 @@ export function mountForm(o) {
951
986
  : null;
952
987
  state.step = step;
953
988
  state.touched = false;
954
- for (const t of state.timers.splice(0)) clearInterval(t);
989
+ for (const t of state.timers.splice(0)) stopTimer(t);
955
990
  state.codes = [];
956
991
  state.videos = [];
957
992
  state.wheel = null;
@@ -1418,7 +1453,7 @@ export function mountForm(o) {
1418
1453
 
1419
1454
  function teardown() {
1420
1455
  state.closed = true;
1421
- for (const t of state.timers.splice(0)) clearInterval(t);
1456
+ for (const t of state.timers.splice(0)) stopTimer(t);
1422
1457
  document.removeEventListener('visibilitychange', visibility);
1423
1458
  small?.removeEventListener?.('change', resize);
1424
1459
  const remove = () => {
@@ -54,6 +54,16 @@ function countListView(s, category) {
54
54
  }
55
55
  }
56
56
 
57
+ /** A cart event's lines as the basket's product ids, each once, as many as RECENT_LIMIT. */
58
+ const basketOf = (products) =>
59
+ [
60
+ ...new Set(
61
+ products
62
+ .map((p) => p?.product_id)
63
+ .filter((id) => typeof id === 'string' && id),
64
+ ),
65
+ ].slice(0, RECENT_LIMIT);
66
+
57
67
  /**
58
68
  * Fold one CDP event into the session signals.
59
69
  *
@@ -169,11 +179,19 @@ export function recordSessionEvent(eventName, properties, campaign) {
169
179
  case 'Product Clicked':
170
180
  case 'Product Added to Wishlist':
171
181
  break;
182
+ case 'Cart Viewed':
183
+ // The basket as the page shows it: its lines are the basket's products, not attention.
184
+ if (products.length > 0) s.basketProducts = basketOf(products);
185
+ writeSignals(s);
186
+ return s;
172
187
  case 'Cart Updated': {
173
188
  // The basket as it now stands, priced by the platform, so a merge, a repricing or a code is
174
189
  // already in it. Summing the lines of Product Added would drift from that, and its line
175
190
  // prices include tax where `value` does not. Its lines are the basket, not attention.
176
191
  // The latest basket is the only one that counts: unpriced, its value is unknown again.
192
+ // Its lines are the basket as it now stands, merges and removals included.
193
+ if (Array.isArray(properties?.products))
194
+ s.basketProducts = basketOf(products);
177
195
  const value = properties?.value;
178
196
  s.basketValue =
179
197
  typeof value === 'number' && Number.isFinite(value) && value >= 0
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.27.0';
1
+ export const VERSION = '3.28.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
package/types/onsite.d.ts CHANGED
@@ -85,9 +85,54 @@ export interface OnsiteForm {
85
85
  prizes?: Array<{ key: string; label: string }>;
86
86
  /** A destination is handed the shopper's code, so the brand's email can carry it. */
87
87
  code_relayed?: true;
88
+ /**
89
+ * One of the form's versions shows products: its decision request carries the visit's context
90
+ * (`OnsiteVisitContext`), and only its.
91
+ */
92
+ visit_context?: true;
93
+ /** What the form collects; empty for one that collects nothing (a message, products). */
88
94
  channels?: Array<'email' | 'sms'>;
89
95
  }
90
96
 
97
+ /**
98
+ * What a form that shows products is decided with, for that request only: the page, and with
99
+ * personalisation consent the basket's product ids (from the cart events the page sends) and the
100
+ * visit's views, at most 20 each, newest first.
101
+ */
102
+ export interface OnsiteVisitContext {
103
+ page_type?: string;
104
+ product_id?: string;
105
+ basket?: string[];
106
+ viewed?: string[];
107
+ }
108
+
109
+ /** One product the server filled a form's products block with. */
110
+ export interface OnsiteServedProduct {
111
+ product_id: string;
112
+ name: string;
113
+ price?: number | null;
114
+ compare_at_price?: number | null;
115
+ currency?: string | null;
116
+ image?: string | null;
117
+ url: string;
118
+ position: number;
119
+ }
120
+
121
+ /**
122
+ * A products block's `served`, as `POST /v1/decisions` fills it (spec/onsite/creative.schema.json
123
+ * `servedProducts`). A preview passes it in the creative itself.
124
+ */
125
+ export interface OnsiteServedProducts {
126
+ recommendation_request_id: string;
127
+ placement: string;
128
+ requested: string;
129
+ recipe: string;
130
+ degraded: boolean;
131
+ strategy?: string | null;
132
+ algorithm_version?: string | null;
133
+ products: OnsiteServedProduct[];
134
+ }
135
+
91
136
  /**
92
137
  * A date question's (`kind`: a `birthday`, its `year` not asked, optional or required, or any
93
138
  * full `date`) or a number question's (`min`, `max`, `decimals`, `unit`).