@thg-agentic/hyper-personalisation-web 3.30.0 → 3.32.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 +44 -5
- package/dist/hp.min.js +3 -3
- package/dist/onsite-audiences.min.js +1 -1
- package/dist/onsite-products.min.js +2 -2
- package/dist/onsite-render.min.js +2 -2
- package/dist/onsite-values.min.js +1 -0
- package/dist/onsite.min.js +1 -1
- package/package.json +3 -2
- package/src/answer-memory.js +101 -0
- package/src/index.js +76 -5
- package/src/onsite/api.js +21 -4
- package/src/onsite/index.js +40 -22
- package/src/onsite/money.js +11 -0
- package/src/onsite/preview.js +24 -12
- package/src/onsite/products.js +3 -9
- package/src/onsite/render.js +9 -7
- package/src/onsite/rich-text.js +12 -1
- package/src/onsite/values-loader.js +6 -0
- package/src/onsite/values.js +218 -0
- package/src/onsite/visit-audiences.js +17 -7
- package/src/onsite-facade.js +20 -2
- package/src/session/record.js +18 -0
- package/src/session/rules.js +62 -7
- package/src/session/signals.js +4 -0
- package/src/storage-scope.js +22 -0
- package/src/version.js +1 -1
- package/src/visit-campaign.js +2 -8
- package/types/index.d.ts +5 -0
- package/types/onsite-preview.d.ts +27 -0
- package/types/onsite.d.ts +39 -2
package/src/session/record.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { currencyCode } from '../context.js';
|
|
1
2
|
import { bump, categoryKey, pathPrefix } from './category.js';
|
|
2
3
|
import { readSignals, writeSignals } from './signals.js';
|
|
3
4
|
|
|
@@ -64,6 +65,18 @@ const basketOf = (products) =>
|
|
|
64
65
|
),
|
|
65
66
|
].slice(0, RECENT_LIMIT);
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How many items a cart event's lines hold: a whole positive quantity as it is, a line without one
|
|
70
|
+
* or with a fractional one (a weight) once, and one of zero, below or not a number not at all.
|
|
71
|
+
*/
|
|
72
|
+
export const basketItemsOf = (products) =>
|
|
73
|
+
products.reduce((n, p) => {
|
|
74
|
+
const q = p?.quantity;
|
|
75
|
+
if (q === undefined || q === null) return n + 1;
|
|
76
|
+
if (typeof q !== 'number' || !Number.isFinite(q) || q <= 0) return n;
|
|
77
|
+
return n + (Number.isInteger(q) ? q : 1);
|
|
78
|
+
}, 0);
|
|
79
|
+
|
|
67
80
|
/**
|
|
68
81
|
* Fold one CDP event into the session signals.
|
|
69
82
|
*
|
|
@@ -192,6 +205,11 @@ export function recordSessionEvent(eventName, properties, campaign) {
|
|
|
192
205
|
// Its lines are the basket as it now stands, merges and removals included.
|
|
193
206
|
if (Array.isArray(properties?.products))
|
|
194
207
|
s.basketProducts = basketOf(products);
|
|
208
|
+
s.basketItems = Array.isArray(properties?.products)
|
|
209
|
+
? basketItemsOf(products)
|
|
210
|
+
: null;
|
|
211
|
+
// The basket's own currency, so its value is never shown in another.
|
|
212
|
+
s.basketCurrency = currencyCode(properties?.currency);
|
|
195
213
|
const value = properties?.value;
|
|
196
214
|
s.basketValue =
|
|
197
215
|
typeof value === 'number' && Number.isFinite(value) && value >= 0
|
package/src/session/rules.js
CHANGED
|
@@ -33,6 +33,16 @@ const STRING_OPERATORS = {
|
|
|
33
33
|
contains: (a, b) => text(b) && tagMatches(a, 'contains', b),
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Operators on an answer (`answer.<question>`): the values the shopper chose against the rule's
|
|
38
|
+
* list, as the SQL compiler reads them. With no answer kept, "is none of" holds, as `not exists`.
|
|
39
|
+
*/
|
|
40
|
+
const LIST_OPERATORS = {
|
|
41
|
+
in: (chosen, list) => list.some((v) => chosen.includes(v)),
|
|
42
|
+
not_in: (chosen, list) => !list.some((v) => chosen.includes(v)),
|
|
43
|
+
all: (chosen, list) => list.every((v) => chosen.includes(v)),
|
|
44
|
+
};
|
|
45
|
+
|
|
36
46
|
/**
|
|
37
47
|
* Every operator this evaluator can apply, including `between`. A contract test keeps the session
|
|
38
48
|
* trait catalogue inside this set, since an unsupported operator fails closed silently.
|
|
@@ -40,6 +50,7 @@ const STRING_OPERATORS = {
|
|
|
40
50
|
export const SESSION_OPERATORS = new Set([
|
|
41
51
|
...Object.keys(OPERATORS),
|
|
42
52
|
...Object.keys(STRING_OPERATORS),
|
|
53
|
+
...Object.keys(LIST_OPERATORS),
|
|
43
54
|
'between',
|
|
44
55
|
]);
|
|
45
56
|
|
|
@@ -71,10 +82,11 @@ const MEASURES = {
|
|
|
71
82
|
/**
|
|
72
83
|
* What a rule's field is worth right now, or null if this build does not know the field.
|
|
73
84
|
*
|
|
74
|
-
* A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`),
|
|
85
|
+
* A field is a counter name, `measure.dimension` (`browseShare.damen/schuhe`), one of the
|
|
75
86
|
* visit's campaign tags (`campaign.source`), which is text and null for a visit that arrived
|
|
76
|
-
* untagged
|
|
77
|
-
*
|
|
87
|
+
* untagged, or an answer (`answer.shopping_for`), the values chosen and empty for none, but null
|
|
88
|
+
* where the signals carry no answers (the onsite runtime's do). Exported so a storefront can
|
|
89
|
+
* explain a personalisation. Unknown fields fail the rule closed.
|
|
78
90
|
*/
|
|
79
91
|
export function measureField(field, signals) {
|
|
80
92
|
const separator = field.indexOf('.');
|
|
@@ -87,6 +99,13 @@ export function measureField(field, signals) {
|
|
|
87
99
|
: null;
|
|
88
100
|
return typeof tag === 'string' ? tag : null;
|
|
89
101
|
}
|
|
102
|
+
if (name === 'answer') {
|
|
103
|
+
const { answers } = signals;
|
|
104
|
+
if (!answers || typeof answers !== 'object') return null;
|
|
105
|
+
return Object.hasOwn(answers, arg) && Array.isArray(answers[arg])
|
|
106
|
+
? answers[arg]
|
|
107
|
+
: [];
|
|
108
|
+
}
|
|
90
109
|
const fn = MEASURES[name];
|
|
91
110
|
return fn && arg ? fn(signals, arg) : null;
|
|
92
111
|
}
|
|
@@ -97,6 +116,14 @@ export function measureField(field, signals) {
|
|
|
97
116
|
export function evaluateSessionRule(rule, signals) {
|
|
98
117
|
const actual = measureField(rule.field, signals);
|
|
99
118
|
if (actual === null) return false;
|
|
119
|
+
if (Array.isArray(actual)) {
|
|
120
|
+
const list = Array.isArray(rule.value) && rule.value.length > 0;
|
|
121
|
+
return (
|
|
122
|
+
list &&
|
|
123
|
+
Object.hasOwn(LIST_OPERATORS, rule.operator) &&
|
|
124
|
+
LIST_OPERATORS[rule.operator](actual, rule.value)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
100
127
|
if (typeof actual === 'string') {
|
|
101
128
|
const compare = STRING_OPERATORS[rule.operator];
|
|
102
129
|
return compare ? compare(actual, rule.value) : false;
|
|
@@ -146,6 +173,25 @@ export function sessionRulesHold(audience, signals) {
|
|
|
146
173
|
: groupResults.every(Boolean);
|
|
147
174
|
}
|
|
148
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Whether an early audience (ADR 0064) holds on the answers this device keeps: its visit rules and
|
|
178
|
+
* its `answer_rules` together, grouped as authored. Never when the signals carry no answers.
|
|
179
|
+
*/
|
|
180
|
+
export function answeredRulesHold(audience, signals) {
|
|
181
|
+
return (
|
|
182
|
+
audience.early === true &&
|
|
183
|
+
Array.isArray(audience.answer_rules) &&
|
|
184
|
+
typeof signals?.answers === 'object' &&
|
|
185
|
+
sessionRulesHold(
|
|
186
|
+
{
|
|
187
|
+
...audience,
|
|
188
|
+
rules: [...(audience.rules ?? []), ...audience.answer_rules],
|
|
189
|
+
},
|
|
190
|
+
signals,
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
149
195
|
/**
|
|
150
196
|
* The audiences this visitor is in right now: precomputed membership from the edge, plus any
|
|
151
197
|
* session audience whose rules hold.
|
|
@@ -229,12 +275,21 @@ export function resolveAudienceMemberships(
|
|
|
229
275
|
if (active.has(audience.slug)) continue;
|
|
230
276
|
const required = audience.requires ?? [];
|
|
231
277
|
if (!required.every(met)) continue;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
278
|
+
// An early audience is in from the answers this device keeps, without the warehouse: they are
|
|
279
|
+
// the shopper's own. Otherwise it is the MIXED audience it ships as.
|
|
280
|
+
const source = answeredRulesHold(audience, visit)
|
|
281
|
+
? 'session'
|
|
282
|
+
: (!audience.mixed || qualified.has(audience.slug)) &&
|
|
283
|
+
audience.rules?.length > 0 &&
|
|
284
|
+
sessionRulesHold(audience, visit)
|
|
285
|
+
? audience.mixed
|
|
286
|
+
? 'mixed'
|
|
287
|
+
: 'session'
|
|
288
|
+
: null;
|
|
289
|
+
if (source) {
|
|
235
290
|
active.add(audience.slug);
|
|
236
291
|
// Mixed is its own source: half of it was decided by a batch run, not by this visit.
|
|
237
|
-
bySlug.set(audience.slug,
|
|
292
|
+
bySlug.set(audience.slug, source);
|
|
238
293
|
admitted = true;
|
|
239
294
|
}
|
|
240
295
|
}
|
package/src/session/signals.js
CHANGED
|
@@ -91,6 +91,10 @@ function emptySignals() {
|
|
|
91
91
|
// The last Cart Updated's `value`, in its currency. Unknown (so every rule on it fails closed)
|
|
92
92
|
// until the visit sends one: a basket carried over from an earlier visit is not zero.
|
|
93
93
|
basketValue: null,
|
|
94
|
+
// How many items the last Cart Updated's lines hold; unknown until one lists them.
|
|
95
|
+
basketItems: null,
|
|
96
|
+
// The last Cart Updated's `currency`, the one its value is in; unknown when it named none.
|
|
97
|
+
basketCurrency: null,
|
|
94
98
|
recentSearches: [],
|
|
95
99
|
// Shown-and-ignored counts, kept apart from `recentProducts` so a rail's own impressions never
|
|
96
100
|
// read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
|
package/src/storage-scope.js
CHANGED
|
@@ -70,6 +70,12 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
|
|
|
70
70
|
* person id (3.26).
|
|
71
71
|
*/
|
|
72
72
|
visitCampaign: 'thg_hp_visit_campaign',
|
|
73
|
+
/**
|
|
74
|
+
* localStorage, per property: the answers given to its onsite forms, each kept 90 days, so an
|
|
75
|
+
* audience on answers is decided as the shopper answers. Only under personalisation consent;
|
|
76
|
+
* question keys and option values, never contact data (3.31).
|
|
77
|
+
*/
|
|
78
|
+
answers: 'thg_hp_answers',
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
/**
|
|
@@ -138,6 +144,22 @@ export function storagePrefix(name, tenant) {
|
|
|
138
144
|
return `${name}:${segment(tenant)}:`;
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Remove every tenant's localStorage keys of one kind on this origin: consent is withdrawn on the
|
|
149
|
+
* device, and a sign-out ends what the device kept for everyone.
|
|
150
|
+
*
|
|
151
|
+
* @param {keyof typeof DEFAULT_STORAGE_NAMES} kind
|
|
152
|
+
*/
|
|
153
|
+
export function forgetEveryScope(kind) {
|
|
154
|
+
const prefix = `${names[kind]}:`;
|
|
155
|
+
try {
|
|
156
|
+
for (const key of Object.keys(localStorage))
|
|
157
|
+
if (key.startsWith(prefix)) localStorage.removeItem(key);
|
|
158
|
+
} catch {
|
|
159
|
+
// Nothing was kept.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
/**
|
|
142
164
|
* Move every legacy `cdp*` key to its current name, once per page, in both stores.
|
|
143
165
|
*
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.32.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/src/visit-campaign.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { SESSION_TIMEOUT_MS } from './session-id.js';
|
|
16
|
-
import { storageKey, storageName } from './storage-scope.js';
|
|
16
|
+
import { forgetEveryScope, storageKey, storageName } from './storage-scope.js';
|
|
17
17
|
|
|
18
18
|
/** The longest a tag is read: the control plane's CAMPAIGN_VALUE_MAX, which the server cuts to. */
|
|
19
19
|
const TAG_MAX = 100;
|
|
@@ -167,11 +167,5 @@ export function keepVisitCampaign(
|
|
|
167
167
|
/** Forget every property's visit campaign on this origin: a sign-out, or consent withdrawn. */
|
|
168
168
|
export function clearVisitCampaign() {
|
|
169
169
|
memory.clear();
|
|
170
|
-
|
|
171
|
-
const prefix = `${storageName('visitCampaign')}:`;
|
|
172
|
-
for (const key of Object.keys(localStorage))
|
|
173
|
-
if (key.startsWith(prefix)) localStorage.removeItem(key);
|
|
174
|
-
} catch {
|
|
175
|
-
/* nothing was kept */
|
|
176
|
-
}
|
|
170
|
+
forgetEveryScope('visitCampaign');
|
|
177
171
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -496,6 +496,11 @@ export interface StorageNames {
|
|
|
496
496
|
preview?: string;
|
|
497
497
|
/** sessionStorage: the debug token from `?hp_debug=` (3.18). Default 'thg_hp_debug'. */
|
|
498
498
|
debug?: string;
|
|
499
|
+
/**
|
|
500
|
+
* localStorage, per property, under personalisation consent: the answers given to its onsite
|
|
501
|
+
* forms, each kept 90 days (3.31). Default 'thg_hp_answers'.
|
|
502
|
+
*/
|
|
503
|
+
answers?: string;
|
|
499
504
|
}
|
|
500
505
|
|
|
501
506
|
/** Where a source's device ids come from: the settings' `hp.anonymousId` (since 3.6). */
|
|
@@ -34,6 +34,17 @@ export interface PreviewOptions {
|
|
|
34
34
|
prizes?: Array<{ key: string; label: string }>;
|
|
35
35
|
/** The basket a progress block is drawn against; 32 when left out. */
|
|
36
36
|
basketValue?: number;
|
|
37
|
+
/**
|
|
38
|
+
* A returning shopper's values for the text's shopper values (`answers` by question, as option
|
|
39
|
+
* values); null or left out, every value shows its fallback, as a new visitor sees it.
|
|
40
|
+
*/
|
|
41
|
+
values?: {
|
|
42
|
+
first_name?: string;
|
|
43
|
+
basket_value?: number;
|
|
44
|
+
basket_items?: number;
|
|
45
|
+
currency?: string;
|
|
46
|
+
answers?: Record<string, string[]>;
|
|
47
|
+
} | null;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
export interface OnsitePreview {
|
|
@@ -56,3 +67,19 @@ export declare function mountPreview(
|
|
|
56
67
|
|
|
57
68
|
/** The countries a phone field's country code picker offers, by ISO code, with their calling codes. */
|
|
58
69
|
export declare const CALLING_CODES: Readonly<Record<string, string>>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A shopper value as the SDK draws it in a form's text: a number, money in `currency`, a date, a
|
|
73
|
+
* time from now or text, in `lang`; with `plural`, a number followed by its word. Null when it
|
|
74
|
+
* cannot be shown (an unknown currency, not a number).
|
|
75
|
+
*/
|
|
76
|
+
export declare function formatValue(
|
|
77
|
+
raw: unknown,
|
|
78
|
+
style: 'number' | 'money' | 'date' | 'relative' | 'text',
|
|
79
|
+
options?: {
|
|
80
|
+
lang?: string;
|
|
81
|
+
currency?: string | null;
|
|
82
|
+
plural?: { one: string; other: string };
|
|
83
|
+
now?: number;
|
|
84
|
+
},
|
|
85
|
+
): string | null;
|
package/types/onsite.d.ts
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
1
|
import type { AnalyticsClient } from './index';
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* RichText: plain text, bold, italic, https links and shopper values. No markup: anything else is
|
|
5
|
+
* dropped.
|
|
6
|
+
*/
|
|
4
7
|
export type RichText =
|
|
5
8
|
| string
|
|
6
9
|
| Array<
|
|
7
|
-
|
|
10
|
+
| string
|
|
11
|
+
| { b: RichText }
|
|
12
|
+
| { i: RichText }
|
|
13
|
+
| { a: RichText; href: string }
|
|
14
|
+
| OnsiteValueNode
|
|
8
15
|
>;
|
|
9
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A shopper value in a heading's or a paragraph's text (3.32): filled in the browser as text, never
|
|
19
|
+
* markup, at most 60 characters. `v` is a key of spec/onsite/values.json: `basket_value`,
|
|
20
|
+
* `basket_items`, `first_name` (the page's, `hp.onsite.values`) or `answer.<question>` (the label
|
|
21
|
+
* of the shopper's answer, under personalisation consent). Unknown, it shows `fallback`, or with
|
|
22
|
+
* the block's `missing: "hide"` the block is not shown. Never inside a link.
|
|
23
|
+
*/
|
|
24
|
+
export interface OnsiteValueNode {
|
|
25
|
+
v: string;
|
|
26
|
+
fallback: string;
|
|
27
|
+
format?: {
|
|
28
|
+
style: 'number' | 'money' | 'date' | 'relative' | 'text';
|
|
29
|
+
/** After a number, the word for its count: `{ one: 'item', other: 'items' }`. */
|
|
30
|
+
plural?: { one: string; other: string };
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the page knows of the shopper for a form's text. Never sent, stored or logged. */
|
|
35
|
+
export interface OnsitePageValues {
|
|
36
|
+
first_name?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
10
39
|
export type OnsiteFormat =
|
|
11
40
|
| 'popup'
|
|
12
41
|
| 'sheet'
|
|
@@ -171,6 +200,8 @@ export interface OnsiteOptions {
|
|
|
171
200
|
/** Log why each form did or did not show. Also on with `?hp_debug=onsite` or the SDK's debug. */
|
|
172
201
|
debug?: boolean;
|
|
173
202
|
fetch?: typeof fetch;
|
|
203
|
+
/** What the page knows of the shopper for a form's text; `hp.onsite.values` replaces it. */
|
|
204
|
+
values?: OnsitePageValues;
|
|
174
205
|
}
|
|
175
206
|
|
|
176
207
|
/**
|
|
@@ -208,6 +239,7 @@ export type OnsiteNothingCode =
|
|
|
208
239
|
| 'no-code'
|
|
209
240
|
| 'renderer-failed'
|
|
210
241
|
| 'products-failed'
|
|
242
|
+
| 'values-failed'
|
|
211
243
|
| 'error';
|
|
212
244
|
|
|
213
245
|
/** One form's state on this page, and why. */
|
|
@@ -291,6 +323,11 @@ export interface OnsiteFacade {
|
|
|
291
323
|
suppress(reason?: string): void;
|
|
292
324
|
/** Lift `reason`, or every reason. */
|
|
293
325
|
unsuppress(reason?: string): void;
|
|
326
|
+
/**
|
|
327
|
+
* What the page knows of the shopper for a form's text (3.32), `{ first_name }`: in this page's
|
|
328
|
+
* memory only, never sent, stored or logged. Each call replaces the last; `values({})` forgets.
|
|
329
|
+
*/
|
|
330
|
+
values(given: OnsitePageValues): void;
|
|
294
331
|
/** Each form's state once the page knows whether onsite forms run on it; `[]` when none will. */
|
|
295
332
|
ready(): Promise<OnsiteTrace[]>;
|
|
296
333
|
debug(): OnsiteTrace[];
|