@thg-agentic/hyper-personalisation-web 3.34.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.
@@ -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
@@ -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
- const value = signals[field];
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
 
@@ -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 = new Set(['products']);
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 in properties && isNullish(properties[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.34.0';
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>;
package/types/onsite.d.ts CHANGED
@@ -105,6 +105,11 @@ export interface OnsiteForm {
105
105
  /** Shown only with personalisation consent, and only to members as the server decides. */
106
106
  audiences?: string[];
107
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;
108
113
  caps?: { per_session?: number; lifetime?: number; after_close_days?: number };
109
114
  /**
110
115
  * Its dates and its daily cut-off: with one, it shows only while one of its listed cut-offs is