@thg-agentic/hyper-personalisation-web 3.27.0 → 3.28.1
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 +21 -9
- package/dist/hp.min.js +3 -3
- package/dist/onsite-products.min.js +16 -0
- package/dist/onsite-render.min.js +2 -2
- package/dist/onsite.min.js +1 -1
- package/package.json +3 -2
- package/src/consent.js +3 -7
- package/src/index.js +33 -0
- 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 +190 -0
- package/src/onsite/render.js +38 -3
- package/src/session/record.js +18 -0
- package/src/spec-version.js +1 -1
- package/src/version.js +1 -1
- package/types/events.d.ts +3 -2
- package/types/onsite.d.ts +45 -0
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
.pf{display:block;aspect-ratio:1;border-radius:var(--ir);background:#f4f4f4;overflow:hidden;isolation:isolate}
|
|
24
|
+
.pf.e{background:color-mix(in srgb,var(--t) 7%,var(--b))}
|
|
25
|
+
.pi{display:block;width:100%;height:100%;object-fit:contain;mix-blend-mode:multiply}
|
|
26
|
+
.pn{font-size:14px;line-height:1.3;min-height:2.6em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
27
|
+
.pp{font-size:14px;font-weight:700}
|
|
28
|
+
.pp s{font-weight:400;color:var(--mu)}
|
|
29
|
+
.pb{margin-top:auto;padding:8px 12px;font-size:14px}
|
|
30
|
+
@media (prefers-reduced-motion:reduce){.pr-c ul{scroll-behavior:auto}}`;
|
|
31
|
+
|
|
32
|
+
const ALIGNS = ['left', 'center', 'right'];
|
|
33
|
+
const isObject = (v) =>
|
|
34
|
+
v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
35
|
+
|
|
36
|
+
/** Money in the form's language; the bare amount for a currency the browser does not know. */
|
|
37
|
+
function moneyIn(lang, currency) {
|
|
38
|
+
try {
|
|
39
|
+
const format = new Intl.NumberFormat(lang || undefined, {
|
|
40
|
+
style: 'currency',
|
|
41
|
+
currency,
|
|
42
|
+
});
|
|
43
|
+
return (n) => format.format(n);
|
|
44
|
+
} catch {
|
|
45
|
+
return (n) => n.toFixed(2);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Draw `block`'s served products into `box`, in the form's shadow `root`.
|
|
51
|
+
*
|
|
52
|
+
* @param {HTMLElement} box The block's node, already on the step.
|
|
53
|
+
* @param {object} block The products block, with the server's `served`.
|
|
54
|
+
* @param {{ el: Function, lang?: string, root: ShadowRoot, placement: string, preview: boolean,
|
|
55
|
+
* client: object|null, engaged: Function, seen: Set<string> }} o `client` is null when nothing
|
|
56
|
+
* is reported; `seen` holds the form's rails already reported seen, so a step shown again is not.
|
|
57
|
+
* @returns {(() => void)|undefined} Stops watching the cards, for when the step changes.
|
|
58
|
+
*/
|
|
59
|
+
export function mountProducts(box, block, o) {
|
|
60
|
+
const { el } = o;
|
|
61
|
+
const served = isObject(block.served) ? block.served : {};
|
|
62
|
+
const limit = Number.isInteger(block.limit)
|
|
63
|
+
? Math.min(Math.max(block.limit, 1), 12)
|
|
64
|
+
: 4;
|
|
65
|
+
const shown = (Array.isArray(served.products) ? served.products : [])
|
|
66
|
+
.filter(
|
|
67
|
+
(p) =>
|
|
68
|
+
isObject(p) && typeof p.product_id === 'string' && httpsHref(p.url),
|
|
69
|
+
)
|
|
70
|
+
.slice(0, limit);
|
|
71
|
+
if (shown.length === 0) return;
|
|
72
|
+
const show = isObject(block.show) ? block.show : {};
|
|
73
|
+
const grid = block.layout === 'grid';
|
|
74
|
+
box.classList.add(grid ? 'pr-g' : 'pr-c');
|
|
75
|
+
if (ALIGNS.includes(block.align)) box.classList.add(`a-${block.align}`);
|
|
76
|
+
// A row that scrolls is named as a region; each card is a link, so Tab walks it into view.
|
|
77
|
+
if (!grid) {
|
|
78
|
+
box.setAttribute('role', 'region');
|
|
79
|
+
box.setAttribute('aria-label', 'Products');
|
|
80
|
+
}
|
|
81
|
+
const style = el('style');
|
|
82
|
+
style.textContent = CSS;
|
|
83
|
+
const list = el('ul');
|
|
84
|
+
list.setAttribute('role', 'list');
|
|
85
|
+
|
|
86
|
+
const tile = (p, position) => ({
|
|
87
|
+
product_id: p.product_id,
|
|
88
|
+
...(typeof p.name === 'string' ? { name: p.name } : {}),
|
|
89
|
+
...(Number.isFinite(p.price) ? { price: p.price } : {}),
|
|
90
|
+
position,
|
|
91
|
+
});
|
|
92
|
+
const client =
|
|
93
|
+
typeof served.recommendation_request_id === 'string' && !o.preview
|
|
94
|
+
? o.client
|
|
95
|
+
: null;
|
|
96
|
+
const viewable = new Map();
|
|
97
|
+
const id = served.recommendation_request_id;
|
|
98
|
+
const seen = o.seen ?? new Set();
|
|
99
|
+
let pending = null;
|
|
100
|
+
const impression = () => {
|
|
101
|
+
clearTimeout(pending);
|
|
102
|
+
if (!client || seen.has(id)) return;
|
|
103
|
+
seen.add(id);
|
|
104
|
+
client.recommendationsViewed({
|
|
105
|
+
recommendation_request_id: id,
|
|
106
|
+
placement: served.placement || o.placement,
|
|
107
|
+
// Required outside a holdout: a list that is the shopper's own (their basket) has no source.
|
|
108
|
+
strategy: served.strategy || served.recipe || block.source,
|
|
109
|
+
recipe: served.recipe ?? null,
|
|
110
|
+
algorithm_version: served.algorithm_version ?? null,
|
|
111
|
+
products: shown.map((p, i) => tile(p, i + 1)),
|
|
112
|
+
viewable: [...viewable.values()].sort((a, b) => a.position - b.position),
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (const [i, p] of shown.entries()) {
|
|
117
|
+
const position = i + 1;
|
|
118
|
+
const item = el('li', 'pc');
|
|
119
|
+
item.dataset.hpProduct = p.product_id;
|
|
120
|
+
item.dataset.hpPosition = String(position);
|
|
121
|
+
const a = el('a', 'pa');
|
|
122
|
+
const name = typeof p.name === 'string' ? p.name : '';
|
|
123
|
+
const image = httpsHref(p.image);
|
|
124
|
+
if (image) {
|
|
125
|
+
const img = el('img', 'pi');
|
|
126
|
+
// The card's name says what it is; with the name hidden, the picture says it.
|
|
127
|
+
img.alt = show.name === false ? name : '';
|
|
128
|
+
img.loading = 'lazy';
|
|
129
|
+
img.decoding = 'async';
|
|
130
|
+
img.src = image;
|
|
131
|
+
// On one light tile, so packshots on white and on grey read as one set.
|
|
132
|
+
const frame = el('span', 'pf');
|
|
133
|
+
frame.append(img);
|
|
134
|
+
a.append(frame);
|
|
135
|
+
} else a.append(el('span', 'pf e'));
|
|
136
|
+
// Kept when empty too, so every price in a row sits on one line.
|
|
137
|
+
if (show.name !== false) a.append(el('span', 'pn', name));
|
|
138
|
+
if (show.price !== false && Number.isFinite(p.price)) {
|
|
139
|
+
const money = moneyIn(o.lang, p.currency);
|
|
140
|
+
const price = el('span', 'pp');
|
|
141
|
+
price.append(el('span', '', money(p.price)));
|
|
142
|
+
if (Number.isFinite(p.compare_at_price) && p.compare_at_price > p.price) {
|
|
143
|
+
const was = el('s', '', money(p.compare_at_price));
|
|
144
|
+
was.setAttribute('aria-label', `was ${money(p.compare_at_price)}`);
|
|
145
|
+
price.append(' ', was);
|
|
146
|
+
}
|
|
147
|
+
a.append(price);
|
|
148
|
+
}
|
|
149
|
+
if (show.name === false && !image)
|
|
150
|
+
a.setAttribute('aria-label', name || p.product_id);
|
|
151
|
+
if (typeof block.button === 'string' && block.button)
|
|
152
|
+
a.append(el('span', 'btn pb', block.button));
|
|
153
|
+
a.href = httpsHref(p.url);
|
|
154
|
+
a.rel = 'noopener';
|
|
155
|
+
a.addEventListener('click', (e) => {
|
|
156
|
+
if (o.preview) return e.preventDefault();
|
|
157
|
+
// The impression first, so the click joins it, as a rail's does.
|
|
158
|
+
impression();
|
|
159
|
+
client?.productClicked({
|
|
160
|
+
recommendation_request_id: served.recommendation_request_id,
|
|
161
|
+
interaction: 'navigate',
|
|
162
|
+
products: [tile(p, position)],
|
|
163
|
+
});
|
|
164
|
+
o.engaged('click', undefined, {
|
|
165
|
+
position,
|
|
166
|
+
products: [tile(p, position)],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
item.append(a);
|
|
170
|
+
list.append(item);
|
|
171
|
+
}
|
|
172
|
+
box.append(style, list);
|
|
173
|
+
if (!client || seen.has(id)) return;
|
|
174
|
+
return watchViewability({
|
|
175
|
+
selector: '[data-hp-product]',
|
|
176
|
+
root: o.root,
|
|
177
|
+
onViewable(node) {
|
|
178
|
+
const position = Number(node.dataset.hpPosition);
|
|
179
|
+
if (!viewable.has(position))
|
|
180
|
+
viewable.set(position, {
|
|
181
|
+
position,
|
|
182
|
+
product_id: node.dataset.hpProduct,
|
|
183
|
+
viewable_at: new Date().toISOString(),
|
|
184
|
+
});
|
|
185
|
+
// Deferred a turn, so cards that became viewable together land on one event.
|
|
186
|
+
pending ??= setTimeout(impression, 0);
|
|
187
|
+
return true;
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
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,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/spec-version.js
CHANGED
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.28.1';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/types/events.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated from spec/registry.json by scripts/generate-sdk-models.mjs.
|
|
2
2
|
// DO NOT EDIT. Change the schema and regenerate — the schema is the contract,
|
|
3
3
|
// and a hand-edit here is an SDK that disagrees with the ingest.
|
|
4
|
-
// Spec version: 1.
|
|
4
|
+
// Spec version: 1.21
|
|
5
5
|
|
|
6
6
|
/** context.source.environment. Environment the producer runs in. */
|
|
7
7
|
export type CdpSourceEnvironment = 'production' | 'staging' | 'development';
|
|
@@ -103,6 +103,7 @@ export interface RecommendationServed {
|
|
|
103
103
|
recipe?: string | null;
|
|
104
104
|
strategy?: string | null;
|
|
105
105
|
algorithm_version?: string | null;
|
|
106
|
+
candidate_algorithm_version?: string | null;
|
|
106
107
|
ranked_by?: 'ranker' | 'blend' | 'session' | 'manual' | 'other' | null;
|
|
107
108
|
fusion?: string | null;
|
|
108
109
|
fast_weight?: number | null;
|
|
@@ -712,7 +713,7 @@ export interface SignUpRelayed {
|
|
|
712
713
|
}
|
|
713
714
|
|
|
714
715
|
/** The spec version these models implement. SDKs stamp it on context.spec.version. */
|
|
715
|
-
export const SPEC_VERSION = '1.
|
|
716
|
+
export const SPEC_VERSION = '1.21';
|
|
716
717
|
|
|
717
718
|
/** Every event name the registry accepts. Anything else is rejected at the ingest. */
|
|
718
719
|
export type CdpEventName =
|
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`).
|