@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.
- package/README.md +25 -7
- package/dist/hp.min.js +3 -3
- package/dist/onsite-audiences.min.js +1 -1
- package/dist/onsite-products.min.js +14 -0
- package/dist/onsite-render.min.js +2 -2
- package/dist/onsite.min.js +1 -1
- package/package.json +2 -1
- package/src/index.js +54 -7
- package/src/onsite/api.js +4 -2
- package/src/onsite/index.js +25 -0
- package/src/onsite/preview.js +2 -0
- package/src/onsite/products-loader.js +6 -0
- package/src/onsite/products.js +184 -0
- package/src/onsite/render.js +38 -3
- package/src/session/record.js +26 -2
- package/src/session/rules.js +33 -3
- package/src/session/signals.js +5 -1
- package/src/version.js +1 -1
- package/src/visit-campaign.js +16 -7
- package/types/onsite.d.ts +45 -0
|
@@ -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
|
+
}
|
package/src/onsite/render.js
CHANGED
|
@@ -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))
|
|
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))
|
|
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 = () => {
|
package/src/session/record.js
CHANGED
|
@@ -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
|
|
package/src/session/rules.js
CHANGED
|
@@ -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
|
|
58
|
-
*
|
|
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
|
|
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 (
|
package/src/session/signals.js
CHANGED
|
@@ -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.
|
|
1
|
+
export const VERSION = '3.28.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/src/visit-campaign.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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`).
|