@thg-agentic/hyper-personalisation-web 3.32.0 → 3.35.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 +54 -8
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +2 -2
- 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-wheel.min.js +1 -1
- package/dist/onsite.min.js +1 -1
- package/package.json +1 -1
- package/src/datalayer.js +7 -2
- package/src/index.js +55 -3
- package/src/onsite/answers.js +6 -3
- package/src/onsite/api.js +5 -2
- package/src/onsite/countdown.js +21 -11
- package/src/onsite/index.js +99 -11
- package/src/onsite/page-facts.js +61 -0
- package/src/onsite/preview.js +5 -2
- package/src/onsite/render.js +5 -2
- package/src/onsite/rules.js +52 -8
- package/src/onsite-facade.js +37 -0
- package/src/segment.js +2 -1
- package/src/session/record.js +61 -0
- package/src/session/rules.js +3 -1
- package/src/session/signals.js +3 -0
- package/src/shape.js +9 -6
- package/src/version.js +1 -1
- package/types/index.d.ts +1 -1
- package/types/onsite-preview.d.ts +13 -0
- package/types/onsite.d.ts +39 -1
package/src/session/record.js
CHANGED
|
@@ -77,6 +77,61 @@ export const basketItemsOf = (products) =>
|
|
|
77
77
|
return n + (Number.isInteger(q) ? q : 1);
|
|
78
78
|
}, 0);
|
|
79
79
|
|
|
80
|
+
/** The events that can change the basket's codes: every other leaves them as they are. */
|
|
81
|
+
export const BASKET_CODE_EVENTS = new Set([
|
|
82
|
+
'Cart Viewed',
|
|
83
|
+
'Cart Updated',
|
|
84
|
+
'Coupon Applied',
|
|
85
|
+
'Coupon Removed',
|
|
86
|
+
'Order Completed',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/** Codes compared as a shopper types them: `welcome10` and `WELCOME10 ` are one code. */
|
|
90
|
+
const sameCode = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
91
|
+
|
|
92
|
+
const codeText = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The discount codes in the basket after `eventName`, from the spec's own fields: a cart event's
|
|
96
|
+
* `discounts` (one entry per code), else its `coupon`, replaces them; Coupon Applied and Coupon
|
|
97
|
+
* Removed adjust them between cart events, and an order uses them up. A cart event naming neither
|
|
98
|
+
* field leaves them as they were. Null is unknown, until an event names them, so nothing is decided
|
|
99
|
+
* on codes never reported.
|
|
100
|
+
*/
|
|
101
|
+
export function basketCodesAfter(codes, eventName, properties) {
|
|
102
|
+
switch (eventName) {
|
|
103
|
+
case 'Cart Viewed':
|
|
104
|
+
case 'Cart Updated': {
|
|
105
|
+
const { discounts, coupon } = properties ?? {};
|
|
106
|
+
if (Array.isArray(discounts))
|
|
107
|
+
return discounts
|
|
108
|
+
.map((d) => codeText(d?.coupon_name))
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.filter((c, i, all) => all.findIndex((o) => sameCode(o, c)) === i);
|
|
111
|
+
if (coupon !== undefined) {
|
|
112
|
+
const code = codeText(coupon);
|
|
113
|
+
return code ? [code] : [];
|
|
114
|
+
}
|
|
115
|
+
// `discounts: null` is a basket without promotions; naming neither leaves them as they were.
|
|
116
|
+
return discounts === null ? [] : codes;
|
|
117
|
+
}
|
|
118
|
+
case 'Coupon Applied': {
|
|
119
|
+
const code = codeText(properties?.coupon_name);
|
|
120
|
+
if (!code) return codes;
|
|
121
|
+
const kept = codes ?? [];
|
|
122
|
+
return kept.some((c) => sameCode(c, code)) ? kept : [...kept, code];
|
|
123
|
+
}
|
|
124
|
+
case 'Coupon Removed': {
|
|
125
|
+
const code = codeText(properties?.coupon_name);
|
|
126
|
+
return code && codes ? codes.filter((c) => !sameCode(c, code)) : codes;
|
|
127
|
+
}
|
|
128
|
+
case 'Order Completed':
|
|
129
|
+
return codes?.length ? [] : codes;
|
|
130
|
+
default:
|
|
131
|
+
return codes;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
80
135
|
/**
|
|
81
136
|
* Fold one CDP event into the session signals.
|
|
82
137
|
*
|
|
@@ -95,6 +150,7 @@ export function recordSessionEvent(eventName, properties, campaign) {
|
|
|
95
150
|
const products = Array.isArray(properties?.products)
|
|
96
151
|
? properties.products
|
|
97
152
|
: [];
|
|
153
|
+
s.basketCodes = basketCodesAfter(s.basketCodes, eventName, properties);
|
|
98
154
|
|
|
99
155
|
switch (eventName) {
|
|
100
156
|
case 'Product Viewed':
|
|
@@ -197,6 +253,11 @@ export function recordSessionEvent(eventName, properties, campaign) {
|
|
|
197
253
|
if (products.length > 0) s.basketProducts = basketOf(products);
|
|
198
254
|
writeSignals(s);
|
|
199
255
|
return s;
|
|
256
|
+
case 'Coupon Applied':
|
|
257
|
+
case 'Coupon Removed':
|
|
258
|
+
case 'Order Completed':
|
|
259
|
+
writeSignals(s);
|
|
260
|
+
return s;
|
|
200
261
|
case 'Cart Updated': {
|
|
201
262
|
// The basket as it now stands, priced by the platform, so a merge, a repricing or a code is
|
|
202
263
|
// already in it. Summing the lines of Product Added would drift from that, and its line
|
package/src/session/rules.js
CHANGED
|
@@ -109,7 +109,9 @@ export function measureField(field, signals) {
|
|
|
109
109
|
const fn = MEASURES[name];
|
|
110
110
|
return fn && arg ? fn(signals, arg) : null;
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
// How many discount codes the basket holds: `eq 0` is a basket without one. Unknown fails closed.
|
|
113
|
+
const value =
|
|
114
|
+
field === 'basketCodeCount' ? signals.basketCodes?.length : signals[field];
|
|
113
115
|
return typeof value === 'number' ? value : null;
|
|
114
116
|
}
|
|
115
117
|
|
package/src/session/signals.js
CHANGED
|
@@ -68,6 +68,7 @@ function readUnscoped() {
|
|
|
68
68
|
(parsed.productViews > 0 ||
|
|
69
69
|
parsed.listViews > 0 ||
|
|
70
70
|
parsed.basketValue != null ||
|
|
71
|
+
parsed.basketCodes != null ||
|
|
71
72
|
parsed.recentProducts?.length ||
|
|
72
73
|
parsed.recentBrowse?.length ||
|
|
73
74
|
parsed.campaign);
|
|
@@ -95,6 +96,8 @@ function emptySignals() {
|
|
|
95
96
|
basketItems: null,
|
|
96
97
|
// The last Cart Updated's `currency`, the one its value is in; unknown when it named none.
|
|
97
98
|
basketCurrency: null,
|
|
99
|
+
// The discount codes in the basket (`basketCodesAfter`); unknown until a cart event names them.
|
|
100
|
+
basketCodes: null,
|
|
98
101
|
recentSearches: [],
|
|
99
102
|
// Shown-and-ignored counts, kept apart from `recentProducts` so a rail's own impressions never
|
|
100
103
|
// read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
|
package/src/shape.js
CHANGED
|
@@ -30,9 +30,13 @@ export const PAGE_TYPES = new Set([
|
|
|
30
30
|
*
|
|
31
31
|
* For `properties` only. Context nulls are load-bearing (`consent.analytics: null` means the CMP
|
|
32
32
|
* has not answered). `products: null` is kept, so a failed product mapping still fails validation
|
|
33
|
-
* rather than looking like an empty result.
|
|
33
|
+
* rather than looking like an empty result. On a cart event `coupon: null` and `discounts: null`
|
|
34
|
+
* are kept too: they say the basket has no code, which a missing field does not. Other events'
|
|
35
|
+
* schemas are closed and may not have them, so there they are pruned as before.
|
|
34
36
|
*/
|
|
35
|
-
const NEVER_PRUNE =
|
|
37
|
+
const NEVER_PRUNE = ['products'];
|
|
38
|
+
const CART_NEVER_PRUNE = ['products', 'coupon', 'discounts'];
|
|
39
|
+
const CART_EVENTS = new Set(['Cart Viewed', 'Cart Updated']);
|
|
36
40
|
|
|
37
41
|
/** Restore an exempt key only when it was null; an array stays pruned. */
|
|
38
42
|
const isNullish = (v) => v === null || v === undefined;
|
|
@@ -58,12 +62,11 @@ function pruneNulls(value, ancestors = new WeakSet()) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Properties as they go on the wire: nulls pruned, `products: null` kept visible. */
|
|
61
|
-
export function pruneProperties(properties) {
|
|
65
|
+
export function pruneProperties(properties, event) {
|
|
62
66
|
if (!properties) return properties;
|
|
63
67
|
const pruned = pruneNulls(properties);
|
|
64
|
-
for (const key of NEVER_PRUNE) {
|
|
65
|
-
if (key
|
|
66
|
-
pruned[key] = properties[key];
|
|
68
|
+
for (const key of CART_EVENTS.has(event) ? CART_NEVER_PRUNE : NEVER_PRUNE) {
|
|
69
|
+
if (properties[key] === null) pruned[key] = null;
|
|
67
70
|
}
|
|
68
71
|
return pruned;
|
|
69
72
|
}
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.35.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/types/index.d.ts
CHANGED
|
@@ -535,7 +535,7 @@ export declare function toProducts(
|
|
|
535
535
|
): CdpProduct[];
|
|
536
536
|
export declare function pruneProperties<
|
|
537
537
|
T extends Record<string, unknown> | null | undefined,
|
|
538
|
-
>(properties: T): T;
|
|
538
|
+
>(properties: T, event?: string): T;
|
|
539
539
|
|
|
540
540
|
/** SHA-256 as lowercase hex. */
|
|
541
541
|
export declare function sha256(value: string): Promise<string>;
|
|
@@ -34,6 +34,8 @@ 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
|
+
/** The form's daily cut-off as the settings carry it (`window.daily`), which a `daily` countdown counts to. */
|
|
38
|
+
daily?: DailyCutoff;
|
|
37
39
|
/**
|
|
38
40
|
* A returning shopper's values for the text's shopper values (`answers` by question, as option
|
|
39
41
|
* values); null or left out, every value shows its fallback, as a new visitor sees it.
|
|
@@ -83,3 +85,14 @@ export declare function formatValue(
|
|
|
83
85
|
now?: number;
|
|
84
86
|
},
|
|
85
87
|
): string | null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A form's daily cut-off as the settings carry it (`window.daily`): the moments it falls, which the
|
|
91
|
+
* server works out in the shop's time zone, and a `daily` countdown counts to the first ahead.
|
|
92
|
+
*/
|
|
93
|
+
export interface DailyCutoff {
|
|
94
|
+
/** The cut-offs, epoch milliseconds, earliest first, up to 8 days on from when they were listed. */
|
|
95
|
+
next: readonly number[];
|
|
96
|
+
/** The form shows only while the next cut-off is at most this many hours away. */
|
|
97
|
+
within_hours?: number;
|
|
98
|
+
}
|
package/types/onsite.d.ts
CHANGED
|
@@ -31,6 +31,16 @@ export interface OnsiteValueNode {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** What a page says about itself, for forms limited by page facts (3.34). */
|
|
35
|
+
export type OnsitePageFacts = Record<string, boolean | number | string>;
|
|
36
|
+
|
|
37
|
+
/** One rule on a page fact: `is` / `is_not` one of `values`, or `at_least` / `at_most` a number. */
|
|
38
|
+
export interface OnsitePageFactRule {
|
|
39
|
+
fact: string;
|
|
40
|
+
op: 'is' | 'is_not' | 'at_least' | 'at_most';
|
|
41
|
+
values: Array<boolean | number | string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
/** What the page knows of the shopper for a form's text. Never sent, stored or logged. */
|
|
35
45
|
export interface OnsitePageValues {
|
|
36
46
|
first_name?: string;
|
|
@@ -85,11 +95,31 @@ export interface OnsiteForm {
|
|
|
85
95
|
match: 'is' | 'contains';
|
|
86
96
|
values: string[];
|
|
87
97
|
}>;
|
|
98
|
+
/**
|
|
99
|
+
* Shown only where the page says so (3.34): rules on the facts it gave with `hp.onsite.facts`.
|
|
100
|
+
* Every rule must hold; a fact the page did not give meets none.
|
|
101
|
+
*/
|
|
102
|
+
page_facts?: OnsitePageFactRule[];
|
|
103
|
+
/** Shown only on the pages of chosen products (3.34): the server checks the page's product. */
|
|
104
|
+
products?: true;
|
|
88
105
|
/** Shown only with personalisation consent, and only to members as the server decides. */
|
|
89
106
|
audiences?: string[];
|
|
90
107
|
exclude_subscribed?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Never shown while the basket holds a discount code, as the page's cart events name them
|
|
110
|
+
* (`discounts`, `coupon`, Coupon Applied and Removed); one held back tries again once it has none.
|
|
111
|
+
*/
|
|
112
|
+
exclude_code_applied?: boolean;
|
|
91
113
|
caps?: { per_session?: number; lifetime?: number; after_close_days?: number };
|
|
92
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Its dates and its daily cut-off: with one, it shows only while one of its listed cut-offs is
|
|
116
|
+
* still ahead and, with `within_hours`, only that close to it (3.33).
|
|
117
|
+
*/
|
|
118
|
+
window?: {
|
|
119
|
+
starts_at?: string;
|
|
120
|
+
ends_at?: string;
|
|
121
|
+
daily?: import('./onsite-preview').DailyCutoff;
|
|
122
|
+
};
|
|
93
123
|
/**
|
|
94
124
|
* The form's language, BCP 47 (`en-IE`): its phone field starts on this region's country code,
|
|
95
125
|
* and a birthday's day and month follow its order and month names. Absent: the page's `lang`.
|
|
@@ -328,6 +358,14 @@ export interface OnsiteFacade {
|
|
|
328
358
|
* memory only, never sent, stored or logged. Each call replaces the last; `values({})` forgets.
|
|
329
359
|
*/
|
|
330
360
|
values(given: OnsitePageValues): void;
|
|
361
|
+
/**
|
|
362
|
+
* What this page says about itself (3.34), such as `{ next_day_eligible: true }`: kept in memory
|
|
363
|
+
* for this page's path only; a form's decision request sends only the facts its rules read,
|
|
364
|
+
* never logged. Each call replaces the last; call it again when the shopper picks another size.
|
|
365
|
+
* `facts({})` forgets. Names are lower case, digits and underscores; values true or false, a
|
|
366
|
+
* number or words; at most 20.
|
|
367
|
+
*/
|
|
368
|
+
facts(given: OnsitePageFacts): void;
|
|
331
369
|
/** Each form's state once the page knows whether onsite forms run on it; `[]` when none will. */
|
|
332
370
|
ready(): Promise<OnsiteTrace[]>;
|
|
333
371
|
debug(): OnsiteTrace[];
|