@thg-agentic/hyper-personalisation-web 3.25.0 → 3.27.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.
@@ -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/shape.js CHANGED
@@ -107,6 +107,10 @@ export function realId(value) {
107
107
  return PLACEHOLDER_IDS.has(text.toLowerCase()) ? null : text;
108
108
  }
109
109
 
110
+ /** An identity token (ADR 0067), or null for anything but a non-empty string. */
111
+ export const tokenOf = (value) =>
112
+ typeof value === 'string' && value !== '' ? value : null;
113
+
110
114
  /** A random UUID: event messageIds and the SDK's own device id. */
111
115
  export function uuid() {
112
116
  if (typeof crypto !== 'undefined' && crypto.randomUUID)
@@ -1,4 +1,4 @@
1
1
  // Generated from the spec by scripts/build-types.mjs. DO NOT EDIT: run `pnpm run build:types`.
2
2
 
3
3
  /** The event specification this SDK emits, stamped on every event as context.spec.version. */
4
- export const SPEC_VERSION = '1.18';
4
+ export const SPEC_VERSION = '1.20';
@@ -64,6 +64,12 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
64
64
  * last closed, submitted) and a subscribed flag. Never contact data (onsite).
65
65
  */
66
66
  onsite: 'thg_hp_onsite',
67
+ /**
68
+ * localStorage, per property: the campaign tags the visit landed with, when it was last active
69
+ * and its session id once kept, ended by 30 minutes without a page or event. Never a device or
70
+ * person id (3.26).
71
+ */
72
+ visitCampaign: 'thg_hp_visit_campaign',
67
73
  });
68
74
 
69
75
  /**
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.25.0';
1
+ export const VERSION = '3.27.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The campaign a visit arrived from: the landing page's `utm_*` tags, as GA4's session source and
3
+ * the warehouse's `profiles.sessions` attribute a session to its landing page view. Judged in the
4
+ * browser, and sent only with the decision request for a form with rules, never logged.
5
+ *
6
+ * One store per property, owned here and written by the client as it starts: localStorage, so every
7
+ * tab of the visit reads the same campaign. The first tagged page of a visit wins; the visit ends,
8
+ * as the SDK's session does, after SESSION_TIMEOUT_MS without a page or an event, or when the session
9
+ * it was kept under is replaced. It holds the tags, when the visit was last active and the session
10
+ * id once one is kept on the device: never a device or person id. Kept before consent as the onsite
11
+ * counters are (ICO, the strictly necessary exception): it is context about this visit, which the
12
+ * site's own forms need on the next page.
13
+ */
14
+
15
+ import { SESSION_TIMEOUT_MS } from './session-id.js';
16
+ import { storageKey, storageName } from './storage-scope.js';
17
+
18
+ /** The longest a tag is read: the control plane's CAMPAIGN_VALUE_MAX, which the server cuts to. */
19
+ const TAG_MAX = 100;
20
+
21
+ export const CAMPAIGN_FIELDS = [
22
+ 'source',
23
+ 'medium',
24
+ 'campaign',
25
+ 'content',
26
+ 'term',
27
+ ];
28
+
29
+ /**
30
+ * `{ source, medium, campaign, content, term }` from `context.campaign` (`readCampaign`, which names
31
+ * `utm_campaign` `name`), only the tags present, each cut to TAG_MAX characters; null without a single `utm_*` (a click id alone is
32
+ * not a campaign).
33
+ *
34
+ * @param {Record<string, string> | null | undefined} campaign
35
+ */
36
+ export function visitCampaign(campaign) {
37
+ if (!campaign || typeof campaign !== 'object') return null;
38
+ const out = {};
39
+ for (const field of CAMPAIGN_FIELDS) {
40
+ const value = campaign[field === 'campaign' ? 'name' : field];
41
+ if (typeof value === 'string' && value)
42
+ out[field] = value.slice(0, TAG_MAX);
43
+ }
44
+ return Object.keys(out).length ? out : null;
45
+ }
46
+
47
+ const norm = (v) => String(v).trim().toLowerCase();
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
+
62
+ /**
63
+ * Whether `campaign` (a `visitCampaign`) meets every rule `{ field, match: 'is' | 'contains',
64
+ * values }`, any value meeting a rule, in any case. No rules: true. No campaign, or a rule this SDK
65
+ * cannot read: false.
66
+ */
67
+ export function arrivedFrom(rules, campaign) {
68
+ return (Array.isArray(rules) ? rules : []).every((rule) => {
69
+ const tag = campaign?.[rule?.field];
70
+ if (!CAMPAIGN_FIELDS.includes(rule?.field) || typeof tag !== 'string')
71
+ return false;
72
+ return []
73
+ .concat(rule.values ?? [])
74
+ .some((v) => tagMatches(tag, rule.match, v));
75
+ });
76
+ }
77
+
78
+ const keyOf = (propertyId) =>
79
+ storageKey(
80
+ storageName('visitCampaign'),
81
+ propertyId == null ? null : String(propertyId),
82
+ );
83
+
84
+ /**
85
+ * What this page kept, by key, for a browser whose localStorage is missing or refused the last
86
+ * write: the campaign then holds for the page, read and expired as the stored one is.
87
+ */
88
+ const memory = new Map();
89
+
90
+ function stored(key) {
91
+ if (memory.has(key)) return memory.get(key);
92
+ try {
93
+ const kept = JSON.parse(localStorage.getItem(key));
94
+ return kept && typeof kept === 'object' ? kept : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function put(key, value) {
101
+ try {
102
+ if (value) localStorage.setItem(key, JSON.stringify(value));
103
+ else localStorage.removeItem(key);
104
+ memory.delete(key);
105
+ } catch {
106
+ if (value) memory.set(key, value);
107
+ else memory.delete(key);
108
+ }
109
+ }
110
+
111
+ /** The kept visit when it is still this one: active within the timeout, and under this session. */
112
+ function live(kept, sessionId, now) {
113
+ return typeof kept?.tags === 'object' &&
114
+ kept.tags !== null &&
115
+ Number.isFinite(kept.at) &&
116
+ now - kept.at < SESSION_TIMEOUT_MS &&
117
+ !(sessionId && kept.sid && kept.sid !== sessionId)
118
+ ? kept
119
+ : null;
120
+ }
121
+
122
+ /**
123
+ * The campaign this visit arrived from (a `visitCampaign`), or null; reads only. `sessionId` is the
124
+ * session kept on the device (the host's, or the SDK's cookie), null while it lives in memory.
125
+ *
126
+ * @param {string|null|undefined} propertyId
127
+ * @param {{ sessionId?: string|null, now?: number }} [options]
128
+ */
129
+ export function readVisitCampaign(
130
+ propertyId,
131
+ { sessionId = null, now = Date.now() } = {},
132
+ ) {
133
+ return live(stored(keyOf(propertyId)), sessionId, now)?.tags ?? null;
134
+ }
135
+
136
+ /**
137
+ * A page or an event of the visit: the kept campaign stays, active from `now`; a visit with none
138
+ * takes `landing`, this page's tags, when it has any. Answers the visit's campaign. `sessionId` is
139
+ * compared with the kept one; it is stored only with `keepSession` (analytics granted).
140
+ *
141
+ * @param {string|null|undefined} propertyId
142
+ * @param {Record<string, string>|null} landing This page's `visitCampaign`, or null.
143
+ * @param {{ sessionId?: string|null, keepSession?: boolean, now?: number }} [options]
144
+ */
145
+ export function keepVisitCampaign(
146
+ propertyId,
147
+ landing,
148
+ { sessionId = null, keepSession = false, now = Date.now() } = {},
149
+ ) {
150
+ const key = keyOf(propertyId);
151
+ const raw = stored(key);
152
+ const kept = live(raw, sessionId, now);
153
+ const tags = kept?.tags ?? landing ?? null;
154
+ // A session id is added only while analytics is granted, as the session cookie is; one kept
155
+ // under an earlier grant stays while consent is pending, so a new session still ends the visit.
156
+ // Withdrawal clears the store (`clearVisitCampaign`).
157
+ if (tags)
158
+ put(key, {
159
+ tags,
160
+ at: now,
161
+ sid: (keepSession ? sessionId : null) ?? kept?.sid ?? null,
162
+ });
163
+ else if (raw) put(key, null);
164
+ return tags;
165
+ }
166
+
167
+ /** Forget every property's visit campaign on this origin: a sign-out, or consent withdrawn. */
168
+ export function clearVisitCampaign() {
169
+ memory.clear();
170
+ try {
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
+ }
177
+ }
@@ -88,6 +88,13 @@ export interface Preset extends DataLayerPreset {
88
88
  export interface Identity {
89
89
  userId?: string | number | null;
90
90
  emailSha256?: string | null;
91
+ /**
92
+ * The identity token your server signed for this user on this page view (ADR 0067): a JWT,
93
+ * ES256 with `typ: "JWT"` over `{ sub: userId, aud: "hyper-personalisation", exp }`. Without
94
+ * it the sign-in is recorded but never joins this device to the user's others, and a promo
95
+ * reminder's code is not shown. Since 3.26.
96
+ */
97
+ token?: string | null;
91
98
  }
92
99
 
93
100
  export interface AutocaptureOptions {
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.18
4
+ // Spec version: 1.20
5
5
 
6
6
  /** context.source.environment. Environment the producer runs in. */
7
7
  export type CdpSourceEnvironment = 'production' | 'staging' | 'development';
@@ -91,6 +91,32 @@ export interface JourneyTriggered {
91
91
  products?: CdpProduct[];
92
92
  }
93
93
 
94
+ /** The serving log: one ranked rail as the recommender answered it, with the features it ranked on, recorded when it was served rather than recomputed later. */
95
+ export interface RecommendationServed {
96
+ recommendation_request_id: string;
97
+ group: 'personalised' | 'holdout' | 'excluded';
98
+ personalised: boolean;
99
+ products: Record<string, unknown>[];
100
+ batch_request_id?: string | null;
101
+ placement?: string | null;
102
+ recipe_requested?: string | null;
103
+ recipe?: string | null;
104
+ strategy?: string | null;
105
+ algorithm_version?: string | null;
106
+ ranked_by?: 'ranker' | 'blend' | 'session' | 'manual' | 'other' | null;
107
+ fusion?: string | null;
108
+ fast_weight?: number | null;
109
+ found?: boolean | null;
110
+ degraded?: boolean | null;
111
+ person_id?: string | null;
112
+ decision_id?: string | null;
113
+ arm?: string | null;
114
+ propensity?: number;
115
+ limit?: number | null;
116
+ candidate_set_size?: number | null;
117
+ unserved?: Record<string, unknown>[] | null;
118
+ }
119
+
94
120
  /** A visitor acted on a personalised surface: clicked it (for example opened a product from a rail), dismissed it, or submitted a step of an onsite form. */
95
121
  export interface SlotEngaged {
96
122
  decision_id: string;
@@ -686,12 +712,13 @@ export interface SignUpRelayed {
686
712
  }
687
713
 
688
714
  /** The spec version these models implement. SDKs stamp it on context.spec.version. */
689
- export const SPEC_VERSION = '1.18';
715
+ export const SPEC_VERSION = '1.20';
690
716
 
691
717
  /** Every event name the registry accepts. Anything else is rejected at the ingest. */
692
718
  export type CdpEventName =
693
719
  | 'Decision Made'
694
720
  | 'Journey Triggered'
721
+ | 'Recommendation Served'
695
722
  | 'Slot Engaged'
696
723
  | 'Recommendations Viewed'
697
724
  | 'Promotion Viewed'
@@ -755,6 +782,7 @@ export type CdpEventName =
755
782
  export interface CdpEventProperties {
756
783
  'Decision Made': DecisionMade;
757
784
  'Journey Triggered': JourneyTriggered;
785
+ 'Recommendation Served': RecommendationServed;
758
786
  'Slot Engaged': SlotEngaged;
759
787
  'Recommendations Viewed': RecommendationsViewed;
760
788
  'Promotion Viewed': PromotionViewed;
package/types/index.d.ts CHANGED
@@ -209,6 +209,12 @@ export interface AnalyticsOptions extends AnalyticsContextSources {
209
209
  */
210
210
  holdAcrossPages?: boolean;
211
211
  getUserId?(): string | null | undefined;
212
+ /**
213
+ * The identity token your server signed for the user `getUserId` names (a JWT, ADR 0067), read
214
+ * once: sent on each identify and with each onsite decision until `reset()`, a sign-out or
215
+ * another user's `identify`. A later sign-in passes its own on `identify`. Since 3.26.
216
+ */
217
+ identityToken?: string | null;
212
218
  /** Observer for every event that will be sent. Must not throw; failures are swallowed. */
213
219
  onEvent?(event: CdpEvent): void;
214
220
 
package/types/onsite.d.ts CHANGED
@@ -47,6 +47,15 @@ export interface OnsiteForm {
47
47
  pages?: { include?: string[]; exclude?: string[] };
48
48
  /** `mobile` is a coarse pointer or a viewport under 768px. Absent: both. */
49
49
  devices?: Array<'desktop' | 'mobile'>;
50
+ /**
51
+ * Shown only to a visit that arrived from a matching campaign: the landing page's `utm_*` tags,
52
+ * kept for the visit. Every rule must hold; any of a rule's values meets it, in any case.
53
+ */
54
+ arrived_from?: Array<{
55
+ field: 'source' | 'medium' | 'campaign' | 'content' | 'term';
56
+ match: 'is' | 'contains';
57
+ values: string[];
58
+ }>;
50
59
  /** Shown only with personalisation consent, and only to members as the server decides. */
51
60
  audiences?: string[];
52
61
  exclude_subscribed?: boolean;