@thg-agentic/hyper-personalisation-web 3.26.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,15 +54,30 @@ 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
  *
60
70
  * Driven by the events the SDK already sends, so a session rule is only ever about something the
61
- * warehouse will also see.
71
+ * warehouse will also see. `campaign`, when given, is the visit's campaign (`readVisitCampaign`),
72
+ * mirrored as it stands so the two never disagree; left out, the kept one stays.
62
73
  */
63
- export function recordSessionEvent(eventName, properties) {
74
+ export function recordSessionEvent(eventName, properties, campaign) {
64
75
  const s = readSignals();
65
76
  if (!s.startedAt) s.startedAt = Date.now();
77
+ const arrived =
78
+ campaign !== undefined &&
79
+ JSON.stringify(campaign ?? null) !== JSON.stringify(s.campaign ?? null);
80
+ if (arrived) s.campaign = campaign ?? null;
66
81
 
67
82
  const products = Array.isArray(properties?.products)
68
83
  ? properties.products
@@ -164,11 +179,19 @@ export function recordSessionEvent(eventName, properties) {
164
179
  case 'Product Clicked':
165
180
  case 'Product Added to Wishlist':
166
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;
167
187
  case 'Cart Updated': {
168
188
  // The basket as it now stands, priced by the platform, so a merge, a repricing or a code is
169
189
  // already in it. Summing the lines of Product Added would drift from that, and its line
170
190
  // prices include tax where `value` does not. Its lines are the basket, not attention.
171
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);
172
195
  const value = properties?.value;
173
196
  s.basketValue =
174
197
  typeof value === 'number' && Number.isFinite(value) && value >= 0
@@ -178,6 +201,7 @@ export function recordSessionEvent(eventName, properties) {
178
201
  return s;
179
202
  }
180
203
  default:
204
+ if (arrived) writeSignals(s);
181
205
  return s;
182
206
  }
183
207
 
@@ -1,3 +1,4 @@
1
+ import { CAMPAIGN_FIELDS, tagMatches } from '../visit-campaign.js';
1
2
  import { categoryKey } from './category.js';
2
3
  import { readSignals } from './signals.js';
3
4
 
@@ -17,12 +18,28 @@ const OPERATORS = {
17
18
  '=': (a, b) => a === b,
18
19
  };
19
20
 
21
+ /**
22
+ * Operators on a text field (the visit's campaign tags): `tagMatches`, the matcher a form's
23
+ * "arrived from" rule uses, so the two agree. A value of the wrong shape matches nothing.
24
+ */
25
+ const text = (v) => typeof v === 'string';
26
+ const is = (a, b) => text(b) && tagMatches(a, 'is', b);
27
+ const STRING_OPERATORS = {
28
+ eq: is,
29
+ equals: is,
30
+ '=': is,
31
+ neq: (a, b) => text(b) && !is(a, b),
32
+ in: (a, b) => Array.isArray(b) && b.some((v) => is(a, v)),
33
+ contains: (a, b) => text(b) && tagMatches(a, 'contains', b),
34
+ };
35
+
20
36
  /**
21
37
  * Every operator this evaluator can apply, including `between`. A contract test keeps the session
22
38
  * trait catalogue inside this set, since an unsupported operator fails closed silently.
23
39
  */
24
40
  export const SESSION_OPERATORS = new Set([
25
41
  ...Object.keys(OPERATORS),
42
+ ...Object.keys(STRING_OPERATORS),
26
43
  'between',
27
44
  ]);
28
45
 
@@ -54,14 +71,23 @@ const MEASURES = {
54
71
  /**
55
72
  * What a rule's field is worth right now, or null if this build does not know the field.
56
73
  *
57
- * A field is a counter name or `measure.dimension` (`browseShare.damen/schuhe`). Exported so a
58
- * storefront can explain a personalisation. Unknown fields fail the rule closed.
74
+ * A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`), or one of the
75
+ * visit's campaign tags (`campaign.source`), which is text and null for a visit that arrived
76
+ * untagged. Exported so a storefront can explain a personalisation. Unknown fields fail the rule
77
+ * closed.
59
78
  */
60
79
  export function measureField(field, signals) {
61
80
  const separator = field.indexOf('.');
62
81
  if (separator > 0) {
63
- const fn = MEASURES[field.slice(0, separator)];
82
+ const name = field.slice(0, separator);
64
83
  const arg = field.slice(separator + 1);
84
+ if (name === 'campaign') {
85
+ const tag = CAMPAIGN_FIELDS.includes(arg)
86
+ ? signals.campaign?.[arg]
87
+ : null;
88
+ return typeof tag === 'string' ? tag : null;
89
+ }
90
+ const fn = MEASURES[name];
65
91
  return fn && arg ? fn(signals, arg) : null;
66
92
  }
67
93
  const value = signals[field];
@@ -71,6 +97,10 @@ export function measureField(field, signals) {
71
97
  export function evaluateSessionRule(rule, signals) {
72
98
  const actual = measureField(rule.field, signals);
73
99
  if (actual === null) return false;
100
+ if (typeof actual === 'string') {
101
+ const compare = STRING_OPERATORS[rule.operator];
102
+ return compare ? compare(actual, rule.value) : false;
103
+ }
74
104
  if (rule.operator === 'between') {
75
105
  // A `between` missing either bound fails closed.
76
106
  return (
@@ -69,7 +69,8 @@ function readUnscoped() {
69
69
  parsed.listViews > 0 ||
70
70
  parsed.basketValue != null ||
71
71
  parsed.recentProducts?.length ||
72
- parsed.recentBrowse?.length);
72
+ parsed.recentBrowse?.length ||
73
+ parsed.campaign);
73
74
  return busy ? parsed : null;
74
75
  } catch {
75
76
  return null;
@@ -101,6 +102,9 @@ function emptySignals() {
101
102
  recentBrowseCategories: [],
102
103
  browsingListing: null,
103
104
  listViewsByCategory: {},
105
+ // The campaign the visit arrived from (`readVisitCampaign`, the first page with utm_* tags).
106
+ // Null for a visit that arrived untagged, so every rule on a campaign tag fails closed.
107
+ campaign: null,
104
108
  startedAt: 0,
105
109
  };
106
110
  }
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.26.0';
1
+ export const VERSION = '3.28.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -46,6 +46,19 @@ export function visitCampaign(campaign) {
46
46
 
47
47
  const norm = (v) => String(v).trim().toLowerCase();
48
48
 
49
+ /**
50
+ * Whether a campaign tag meets one wanted value, trimmed and in any case: `is` the whole tag,
51
+ * `contains` a part of it that is not empty. The one matcher behind a form's `arrived_from` and a
52
+ * session audience's campaign rules, so the two cannot disagree.
53
+ */
54
+ export function tagMatches(tag, match, want) {
55
+ const have = norm(tag);
56
+ const wanted = norm(want);
57
+ return match === 'is'
58
+ ? have === wanted
59
+ : match === 'contains' && wanted !== '' && have.includes(wanted);
60
+ }
61
+
49
62
  /**
50
63
  * Whether `campaign` (a `visitCampaign`) meets every rule `{ field, match: 'is' | 'contains',
51
64
  * values }`, any value meeting a rule, in any case. No rules: true. No campaign, or a rule this SDK
@@ -56,13 +69,9 @@ export function arrivedFrom(rules, campaign) {
56
69
  const tag = campaign?.[rule?.field];
57
70
  if (!CAMPAIGN_FIELDS.includes(rule?.field) || typeof tag !== 'string')
58
71
  return false;
59
- const have = norm(tag);
60
- return [].concat(rule.values ?? []).some((v) => {
61
- const want = norm(v);
62
- return rule.match === 'is'
63
- ? have === want
64
- : rule.match === 'contains' && want !== '' && have.includes(want);
65
- });
72
+ return []
73
+ .concat(rule.values ?? [])
74
+ .some((v) => tagMatches(tag, rule.match, v));
66
75
  });
67
76
  }
68
77
 
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`).