@thg-agentic/hyper-personalisation-web 3.24.1 → 3.26.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.
@@ -3,6 +3,8 @@
3
3
  * counters, so the eligibility trace (`hp.onsite.debug()`) says exactly what decided.
4
4
  */
5
5
 
6
+ import { arrivedFrom } from '../visit-campaign.js';
7
+
6
8
  export const FORMATS = [
7
9
  'popup',
8
10
  'sheet',
@@ -39,6 +41,11 @@ const strings = (v) =>
39
41
  Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
40
42
  const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
41
43
 
44
+ /** A rule no visit meets: what an `arrived_from` this SDK cannot read becomes, so it fails closed. */
45
+ const NEVER = { field: null, match: null, values: [] };
46
+ const arrivedRules = (raw) =>
47
+ raw == null ? [] : Array.isArray(raw) && raw.every(isObject) ? raw : [NEVER];
48
+
42
49
  /** A form from the settings as the runtime uses it, or null when it cannot be used. */
43
50
  export function readForm(raw) {
44
51
  if (!isObject(raw) || typeof raw.slot !== 'string' || raw.slot === '')
@@ -55,6 +62,7 @@ export function readForm(raw) {
55
62
  devices: strings(raw.devices),
56
63
  aliases: strings(raw.aliases),
57
64
  audiences: strings(raw.audiences),
65
+ arrived_from: arrivedRules(raw.arrived_from),
58
66
  caps: {
59
67
  per_session: count(caps.per_session),
60
68
  lifetime: count(caps.lifetime),
@@ -123,25 +131,32 @@ const at = (iso) => (typeof iso === 'string' ? Date.parse(iso) : Number.NaN);
123
131
  /**
124
132
  * A form the site opens itself (`manual`: its own sign-up button) is the shopper asking for it, so
125
133
  * how often it has shown does not hold it back: no caps, gap or page view allowance, as Klaviyo's
126
- * custom-triggered forms ignore their frequency settings. Its pages, devices, dates and audiences
127
- * still do.
134
+ * custom-triggered forms ignore their frequency settings. Its pages, devices, campaign, dates and
135
+ * audiences still do.
128
136
  */
129
137
  export const ASKED = (form) => form.trigger.type === 'manual';
130
138
 
131
139
  /**
132
140
  * Every reason `form` may not show now, empty when it may. `env` is the page:
133
- * `{ path, device, now, seen, subscribed, personalisation, preview, audiences }`, where `seen` is
134
- * the slot's counters and `audiences` the session audiences the host gave, or null.
141
+ * `{ path, device, campaign, now, seen, subscribed, personalisation, preview, audiences }`, where
142
+ * `campaign` is the visit's (`visitCampaign`), `seen` the slot's counters and `audiences` the
143
+ * session audiences the host gave, or null. The campaign is context about this visit, as the page
144
+ * is, so it needs no personalisation consent. A form limited to audiences needs personalisation
145
+ * consent; whether the visitor is in one is the server's to decide (`POST /v1/decisions`), unless
146
+ * the host gave its own list.
135
147
  */
136
148
  export function blockers(form, env) {
137
149
  const reasons = [];
138
150
  if (!pageAllowed(form.pages, env.path)) reasons.push('page not included');
139
151
  if (form.devices.length && !form.devices.includes(env.device))
140
152
  reasons.push(`not on ${env.device}`);
153
+ if (!arrivedFrom(form.arrived_from, env.campaign))
154
+ reasons.push('not from its campaign');
141
155
  const starts = at(form.window.starts_at);
142
156
  const ends = at(form.window.ends_at);
143
157
  if (env.now < starts || env.now >= ends) reasons.push('outside its dates');
144
- if (form.audiences.length) {
158
+ // A trader previewing a limited form sees it, as the server answers a preview.
159
+ if (form.audiences.length && !env.preview) {
145
160
  if (!env.personalisation)
146
161
  reasons.push('audiences need personalisation consent');
147
162
  else if (
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The session audiences whose rules hold in this visit, for a form limited to audiences (ADR 0064):
3
+ * the server decides membership, but the live tier counts only product views and cart adds, so the
4
+ * browser evaluates the datafile's session rules and sends the slugs as `visit_audiences`. Rules
5
+ * only: `requires`, MIXED and targetability are the server's to apply. A chunk of its own
6
+ * (./audiences-loader.js), loaded with the datafile as the runtime starts, only when a live form is
7
+ * limited and the visitor allowed personalisation.
8
+ */
9
+
10
+ import { catalogueBrowse, sessionRulesHold } from '../session/rules.js';
11
+
12
+ /** What the decide request accepts: more, or a longer slug, and it ignores the lot. */
13
+ const MAX_AUDIENCES = 100;
14
+ /** Past any slug the console makes: a name of at most 100 characters, and a `-2` to tell twins apart. */
15
+ const MAX_SLUG = 128;
16
+
17
+ const isSlug = (slug) =>
18
+ typeof slug === 'string' && slug !== '' && slug.length <= MAX_SLUG;
19
+
20
+ /** The datafile for each onsite runtime's api: a 200 kept for the page, anything else asked again. */
21
+ const datafiles = new WeakMap();
22
+
23
+ /** Start fetching the datafile, so a form that fires later does not wait for it. */
24
+ export function prefetch(api) {
25
+ if (!datafiles.has(api)) {
26
+ const fetching = api
27
+ .datafile()
28
+ .then((res) => (res.status === 200 ? res.body : null))
29
+ .catch(() => null);
30
+ datafiles.set(api, fetching);
31
+ fetching.then((body) => {
32
+ if (!body && datafiles.get(api) === fetching) datafiles.delete(api);
33
+ });
34
+ }
35
+ return datafiles.get(api);
36
+ }
37
+
38
+ /**
39
+ * The slugs to send: those whose rules hold in this visit (the datafile fetched once, the visit read
40
+ * each time) and those the page gave, once each, at most MAX_AUDIENCES of them. Past that, the form's
41
+ * own audiences and what they require go first, so the cap never drops the ones it asks about.
42
+ *
43
+ * @param {{ datafile: () => Promise<{ status: number, body: any }> }} api ./api.js
44
+ * @param {() => object | null | undefined} [signals] This visit's session signals, kept only under
45
+ * personalisation consent.
46
+ * @param {string[]} [targets] The audiences the form is limited to.
47
+ * @param {string[]} [given] The session audiences the page gave (`startOnsite`'s `audiences()`).
48
+ * @returns {Promise<string[]>}
49
+ */
50
+ export async function visitAudiences(api, signals, targets = [], given = []) {
51
+ const datafile = await prefetch(api);
52
+ const visit = signals?.();
53
+ const session = Array.isArray(datafile?.session_audiences)
54
+ ? datafile.session_audiences
55
+ : [];
56
+ const held = datafile && visit ? holdingAudiences(datafile, visit) : [];
57
+ return firstWanted([...held, ...given], targets, session);
58
+ }
59
+
60
+ /** `slugs`, once each, `targets` and their transitive `requires` first, capped. */
61
+ export function firstWanted(slugs, targets, session) {
62
+ const requires = new Map(
63
+ session.map((a) => [a?.slug, Array.isArray(a?.requires) ? a.requires : []]),
64
+ );
65
+ const wanted = new Set();
66
+ const visit = (slug) => {
67
+ if (wanted.has(slug)) return;
68
+ wanted.add(slug);
69
+ for (const required of requires.get(slug) ?? []) visit(required);
70
+ };
71
+ for (const slug of targets) visit(slug);
72
+ const unique = [...new Set(slugs.filter(isSlug))];
73
+ return [
74
+ ...unique.filter((slug) => wanted.has(slug)),
75
+ ...unique.filter((slug) => !wanted.has(slug)),
76
+ ].slice(0, MAX_AUDIENCES);
77
+ }
78
+
79
+ /**
80
+ * @param {{ session_audiences?: unknown, browse_categories?: unknown }} datafile `GET /v1/audiences/datafile`.
81
+ * @param {object} signals This visit's session signals.
82
+ * @returns {string[]}
83
+ */
84
+ export function holdingAudiences(datafile, signals) {
85
+ const audiences = Array.isArray(datafile?.session_audiences)
86
+ ? datafile.session_audiences
87
+ : [];
88
+ const visit = catalogueBrowse(signals, datafile.browse_categories ?? null);
89
+ return audiences
90
+ .filter(
91
+ (audience) => isSlug(audience?.slug) && sessionRulesHold(audience, visit),
92
+ )
93
+ .map((audience) => audience.slug);
94
+ }
@@ -16,6 +16,8 @@ const LABEL_TO = 0.95;
16
16
  /** Its type: as large as fits up to the first, never under the second (about 11px on a 320px wheel). */
17
17
  const FONT_MAX = 0.105;
18
18
  const FONT_MIN = 0.082;
19
+ /** The least a label is shrunk to before it is shortened: a smaller label beats a cut one. */
20
+ const FONT_FLOOR = 0.062;
19
21
  /** A bold sans character's width, a little over the average, so a label is never cut. */
20
22
  const EM_PER_CHAR = 0.62;
21
23
  const LINE_HEIGHT = 1.15;
@@ -49,8 +51,9 @@ const point = (deg, r = 1) => {
49
51
 
50
52
  /**
51
53
  * How a prize label fits its slice: on one line at the largest size that fits; else on two, broken
52
- * at the space nearest its middle; else shortened with an ellipsis at the smallest size. A slice's
53
- * width near the hub bounds the size too, so many slices draw smaller type.
54
+ * at the space nearest its middle; else the same again down to a smaller floor; else shortened with
55
+ * an ellipsis at the floor. A slice's width near the hub bounds the size too, so many slices draw
56
+ * smaller type.
54
57
  *
55
58
  * @returns {{ lines: string[], size: number }}
56
59
  */
@@ -61,20 +64,60 @@ export function fitLabel(label, slices) {
61
64
  const top = (lines) =>
62
65
  Math.max(FONT_MIN, Math.min(FONT_MAX, across / (lines * LINE_HEIGHT)));
63
66
  const sizeFor = (text) => room / (Math.max(1, text.length) * EM_PER_CHAR);
64
- const one = sizeFor(label);
65
- if (one >= FONT_MIN) return { lines: [label], size: Math.min(top(1), one) };
66
67
  const spaces = [...label.matchAll(/ /g)].map((m) => m.index);
67
- if (spaces.length) {
68
- const middle = label.length / 2;
69
- const at = spaces.reduce((a, b) =>
70
- Math.abs(b - middle) < Math.abs(a - middle) ? b : a,
68
+ const middle = label.length / 2;
69
+ const at = spaces.length
70
+ ? spaces.reduce((a, b) =>
71
+ Math.abs(b - middle) < Math.abs(a - middle) ? b : a,
72
+ )
73
+ : -1;
74
+ const two = at < 0 ? null : [label.slice(0, at), label.slice(at + 1)];
75
+ for (const least of [FONT_MIN, FONT_FLOOR]) {
76
+ const one = sizeFor(label);
77
+ if (one >= least) return { lines: [label], size: Math.min(top(1), one) };
78
+ if (two) {
79
+ const size = Math.min(top(2), ...two.map(sizeFor));
80
+ if (size >= least) return { lines: two, size };
81
+ }
82
+ }
83
+ const fits = Math.floor(room / (FONT_FLOOR * EM_PER_CHAR));
84
+ return {
85
+ lines: [`${label.slice(0, fits - 1).trimEnd()}…`],
86
+ size: FONT_FLOOR,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Hold drawn labels to their room by their real width, once the page lays them out: the estimate
92
+ * in `fitLabel` cannot know the site's font, and a wide one ran "15% off" past its slice's edge.
93
+ * A line too wide is shrunk to fit, down to the floor, and squeezed to the room past it. Labels not
94
+ * laid out yet (a detached wheel) measure nothing and are left as estimated.
95
+ *
96
+ * @param {SVGTextElement[]} texts
97
+ */
98
+ export function fitDrawnLabels(texts) {
99
+ const room = LABEL_TO - LABEL_FROM;
100
+ const width = (span) => span.getComputedTextLength?.() ?? 0;
101
+ for (const text of texts) {
102
+ const spans = [...text.querySelectorAll('tspan')];
103
+ const widest = Math.max(0, ...spans.map(width));
104
+ if (!(widest > room)) continue;
105
+ const size = Math.max(
106
+ FONT_FLOOR,
107
+ (Number(text.getAttribute('font-size')) * room) / widest,
71
108
  );
72
- const lines = [label.slice(0, at), label.slice(at + 1)];
73
- const size = Math.min(top(2), ...lines.map(sizeFor));
74
- if (size >= FONT_MIN) return { lines, size };
109
+ text.setAttribute('font-size', size.toFixed(4));
110
+ spans.forEach((span, k) => {
111
+ span.setAttribute(
112
+ 'y',
113
+ ((k - (spans.length - 1) / 2) * size * LINE_HEIGHT).toFixed(4),
114
+ );
115
+ if (width(span) > room) {
116
+ span.setAttribute('textLength', room.toFixed(4));
117
+ span.setAttribute('lengthAdjust', 'spacingAndGlyphs');
118
+ }
119
+ });
75
120
  }
76
- const fits = Math.floor(room / (FONT_MIN * EM_PER_CHAR));
77
- return { lines: [`${label.slice(0, fits - 1).trimEnd()}…`], size: FONT_MIN };
78
121
  }
79
122
 
80
123
  /**
@@ -108,6 +151,7 @@ export function mountWheel(container, prizes) {
108
151
  ];
109
152
  wheel.append(paint(make('circle', { r: 1 }), BG));
110
153
  const lights = [];
154
+ const texts = [];
111
155
  labels.forEach((label, i) => {
112
156
  const [tone, ink] = tones[n % 2 === 1 && i === n - 1 ? 2 : i % 2];
113
157
  wheel.append(
@@ -148,6 +192,7 @@ export function mountWheel(container, prizes) {
148
192
  text.append(span);
149
193
  });
150
194
  wheel.append(text);
195
+ texts.push(text);
151
196
  for (const at of [i * size, (i + 0.5) * size]) {
152
197
  const [cx, cy] = point(at, 1.075).split(' ');
153
198
  lights.push(paint(make('circle', { cx, cy, r: 0.024 }), BG));
@@ -170,6 +215,9 @@ export function mountWheel(container, prizes) {
170
215
  ),
171
216
  );
172
217
  container.append(svg);
218
+ fitDrawnLabels(texts);
219
+ // A wheel drawn before its step is shown has no layout yet: measured again on the next frame.
220
+ globalThis.requestAnimationFrame?.(() => fitDrawnLabels(texts));
173
221
 
174
222
  return {
175
223
  spinTo(index) {
package/src/settings.js CHANGED
@@ -21,6 +21,18 @@ const isStringArray = (v) =>
21
21
 
22
22
  export const DISABLED = Object.freeze({ enabled: false });
23
23
 
24
+ /**
25
+ * What a page with no cached settings starts on when the API cannot be asked: collection only, every
26
+ * other setting read as absent. Settings switch features on; their absence never switches capture
27
+ * off (Snowplow's tracker needs no remote config to collect). Consent is still the page's CMP,
28
+ * strictly. The property is the page's, else the one the ingest stamps for a single-property key.
29
+ * URLs keep campaign parameters only. Never cached: the next page asks the API again.
30
+ */
31
+ export const BUILT_IN = Object.freeze({
32
+ enabled: true,
33
+ queryParamAllowlist: Object.freeze([]),
34
+ });
35
+
24
36
  /**
25
37
  * Whether a tracking plan lets the track event `name` through. Segment's `plan.track` semantics: an
26
38
  * event marked `enabled: false` is dropped at the source, and `__default` covers events the plan
@@ -218,19 +230,35 @@ export function readSettings(body) {
218
230
  }
219
231
 
220
232
  /**
221
- * The settings for `writeKey`: DISABLED when the API says so, null when it could not be asked (an
222
- * outage keeps whatever the cache last held rather than switching a working site off).
233
+ * How long a page without cached settings waits for them. The answer is cached at the edge, so one
234
+ * this slow is an outage, and a host that awaits `bootstrap` must never wait on HP for longer.
235
+ */
236
+ export const SETTINGS_TIMEOUT_MS = 3000;
237
+
238
+ /**
239
+ * The settings for `writeKey`: DISABLED when the API says so, null when it could not be asked in
240
+ * time (an outage keeps whatever the cache last held rather than switching a working site off).
223
241
  */
224
242
  export async function fetchSettings(
225
243
  apiHost,
226
244
  writeKey,
227
245
  fetchImpl = globalThis.fetch,
246
+ timeoutMs = SETTINGS_TIMEOUT_MS,
228
247
  ) {
229
248
  try {
230
249
  const res = await fetchImpl(settingsUrl(apiHost, writeKey), {
231
250
  credentials: 'omit',
232
251
  headers: { accept: 'application/json' },
252
+ signal: AbortSignal.timeout?.(timeoutMs),
233
253
  });
254
+ // An answered refusal is the key's; only an error, a throttle or a timeout is an outage.
255
+ if (
256
+ res.status >= 400 &&
257
+ res.status < 500 &&
258
+ res.status !== 408 &&
259
+ res.status !== 429
260
+ )
261
+ return DISABLED;
234
262
  if (!res.ok) return null;
235
263
  const body = await res.json();
236
264
  return isSettingsAnswer(body) ? readSettings(body) : null;
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)
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { parseConsentCookie, servingHeaders } from './consent.js';
9
9
  import { getCookie } from './context.js';
10
- import { resolveApiHost } from './settings.js';
10
+ import { resolveApiHost, SETTINGS_TIMEOUT_MS } from './settings.js';
11
11
  import { storageName } from './storage-scope.js';
12
12
 
13
13
  const DEFAULT_TTL_MS = 60_000;
@@ -37,6 +37,7 @@ const chunk = (items, size) => {
37
37
  * @param {number} [options.ttlMs] How long to reuse an answer. The numbers move on a pipeline
38
38
  * schedule of tens of minutes.
39
39
  * @param {typeof fetch} [options.fetchImpl]
40
+ * @param {number} [options.timeoutMs] How long to wait for an answer, as for the settings (3 s).
40
41
  * @param {() => object|null} [options.getConsent] The visitor's consent state, sent as `hp-consent`
41
42
  * like every serving request. Default: read back from the consent cookie the SDK writes; pass
42
43
  * `() => analytics.consent` to use the live state instead.
@@ -48,6 +49,7 @@ export function createSocialProof({
48
49
  ttlMs = DEFAULT_TTL_MS,
49
50
  fetchImpl,
50
51
  getConsent,
52
+ timeoutMs = SETTINGS_TIMEOUT_MS,
51
53
  }) {
52
54
  const base = resolveApiHost(apiHost);
53
55
  if (!base)
@@ -74,6 +76,8 @@ export function createSocialProof({
74
76
  // No credentials: the answer is the same for every visitor, and a cookie would make it
75
77
  // uncacheable.
76
78
  credentials: 'omit',
79
+ // A badge is never worth a wait: an API that does not answer in time is no answer.
80
+ signal: AbortSignal.timeout?.(timeoutMs),
77
81
  headers: {
78
82
  accept: 'application/json',
79
83
  authorization: `Bearer ${writeKey}`,
@@ -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';
@@ -11,7 +11,7 @@
11
11
  export const DEFAULT_STORAGE_NAMES = Object.freeze({
12
12
  /** Cookie: the visitor's consent decision, for the site's own servers to forward. */
13
13
  consent: 'thg_hp_consent',
14
- /** localStorage, per property: batches waiting for the ingest to answer. */
14
+ /** localStorage, per write key (property before 3.25): batches waiting for the ingest to answer. */
15
15
  retry: 'thg_hp_retry',
16
16
  /** localStorage per property and session (sessionStorage until a session is bound). */
17
17
  sessionSignals: 'thg_hp_session_signals',
@@ -24,7 +24,7 @@ export const DEFAULT_STORAGE_NAMES = Object.freeze({
24
24
  /** localStorage, per write key: the last good remote settings. */
25
25
  settings: 'thg_hp_settings',
26
26
  /**
27
- * sessionStorage, per property: events held for an unanswered CMP, carried to the next page.
27
+ * sessionStorage, per write key: events held for an unanswered CMP, carried to the next page.
28
28
  * Only with `holdAcrossPages` (3.5).
29
29
  */
30
30
  held: 'thg_hp_held',
@@ -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/transport.js CHANGED
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * 1. Persisted retry: every batch is written to localStorage before its first attempt and
7
7
  * removed only once the ingest has answered for it.
8
- * 2. Unload delivery: on pagehide the queue goes by sendBeacon, which outlives the document.
8
+ * 2. Unload delivery: on pagehide the queue goes by sendBeacon, which outlives the document. A
9
+ * beacon's answer is never seen, so while the ingest is failing a copy is also persisted.
9
10
  * 3. Bounded: queue size, retry age and payload size are capped, so a broken ingest degrades
10
11
  * instead of filling the user's disk.
11
12
  */
@@ -103,6 +104,10 @@ export class Transport {
103
104
  this.wakers = new Set();
104
105
  this.replayAllowed = !this.o.deferReplay;
105
106
  this.draining = null;
107
+ /** The last attempt failed, or the host was found unreachable: unload persists what it beacons. */
108
+ this.failing = false;
109
+ /** The ingest has accepted a batch on this page: only a send that fails says otherwise. */
110
+ this.answered = false;
106
111
 
107
112
  if (isBrowser()) {
108
113
  // pagehide is the unload, and the beacon the only send sure to outlive it. A hidden tab may
@@ -115,6 +120,11 @@ export class Transport {
115
120
  }
116
121
  }
117
122
 
123
+ /** The ingest is taken as down until a send succeeds, unless one already has: see #flushBeacon. */
124
+ markFailing() {
125
+ if (!this.answered) this.failing = true;
126
+ }
127
+
118
128
  /** Allow, and start, the replay of persisted batches. Idempotent. */
119
129
  replayPersisted() {
120
130
  this.replayAllowed = true;
@@ -292,6 +302,8 @@ export class Transport {
292
302
  * Unload: every chunk that fits goes by beacon. A chunk the browser refuses (its keepalive
293
303
  * quota is shared across the page) is persisted for the next page load AND tried by fetch;
294
304
  * a fetch that lands removes it again, and a duplicate is deduplicated on messageId downstream.
305
+ * While the ingest is failing a beaconed chunk is persisted too, since the browser accepting a
306
+ * beacon says nothing of the ingest: the next page replays it, a duplicate if it did land.
295
307
  *
296
308
  * The fetch builds its own body rather than reusing the beacon's, for the same reason every retry
297
309
  * does: see #send.
@@ -318,7 +330,12 @@ export class Transport {
318
330
  } catch {
319
331
  ok = false;
320
332
  }
321
- if (ok) continue;
333
+ if (ok) {
334
+ // Claimed, so a page restored from the back/forward cache does not replay it itself.
335
+ const id = this.failing ? this.#persist(batch) : null;
336
+ if (id) this.sending.add(id);
337
+ continue;
338
+ }
322
339
  }
323
340
  this.#send(batch, 0, this.#persist(batch));
324
341
  }
@@ -346,6 +363,8 @@ export class Transport {
346
363
  });
347
364
 
348
365
  if (res.ok) {
366
+ this.failing = false;
367
+ this.answered = true;
349
368
  this.#settle(persisted);
350
369
  // The ingest is answering again: whatever an earlier outage left behind can go now.
351
370
  this.#drainPersisted();
@@ -365,6 +384,7 @@ export class Transport {
365
384
  }
366
385
  throw new Error(`retryable ${res.status}`);
367
386
  } catch (err) {
387
+ this.failing = true;
368
388
  if (attempt >= this.o.maxRetries) {
369
389
  if (persisted) this.sending.delete(persisted);
370
390
  const fate = persisted ? 'kept for the next page' : 'dropped';
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.24.1';
1
+ export const VERSION = '3.26.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -0,0 +1,168 @@
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 `campaign` (a `visitCampaign`) meets every rule `{ field, match: 'is' | 'contains',
51
+ * values }`, any value meeting a rule, in any case. No rules: true. No campaign, or a rule this SDK
52
+ * cannot read: false.
53
+ */
54
+ export function arrivedFrom(rules, campaign) {
55
+ return (Array.isArray(rules) ? rules : []).every((rule) => {
56
+ const tag = campaign?.[rule?.field];
57
+ if (!CAMPAIGN_FIELDS.includes(rule?.field) || typeof tag !== 'string')
58
+ return false;
59
+ const have = norm(tag);
60
+ return [].concat(rule.values ?? []).some((v) => {
61
+ const want = norm(v);
62
+ return rule.match === 'is'
63
+ ? have === want
64
+ : rule.match === 'contains' && want !== '' && have.includes(want);
65
+ });
66
+ });
67
+ }
68
+
69
+ const keyOf = (propertyId) =>
70
+ storageKey(
71
+ storageName('visitCampaign'),
72
+ propertyId == null ? null : String(propertyId),
73
+ );
74
+
75
+ /**
76
+ * What this page kept, by key, for a browser whose localStorage is missing or refused the last
77
+ * write: the campaign then holds for the page, read and expired as the stored one is.
78
+ */
79
+ const memory = new Map();
80
+
81
+ function stored(key) {
82
+ if (memory.has(key)) return memory.get(key);
83
+ try {
84
+ const kept = JSON.parse(localStorage.getItem(key));
85
+ return kept && typeof kept === 'object' ? kept : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function put(key, value) {
92
+ try {
93
+ if (value) localStorage.setItem(key, JSON.stringify(value));
94
+ else localStorage.removeItem(key);
95
+ memory.delete(key);
96
+ } catch {
97
+ if (value) memory.set(key, value);
98
+ else memory.delete(key);
99
+ }
100
+ }
101
+
102
+ /** The kept visit when it is still this one: active within the timeout, and under this session. */
103
+ function live(kept, sessionId, now) {
104
+ return typeof kept?.tags === 'object' &&
105
+ kept.tags !== null &&
106
+ Number.isFinite(kept.at) &&
107
+ now - kept.at < SESSION_TIMEOUT_MS &&
108
+ !(sessionId && kept.sid && kept.sid !== sessionId)
109
+ ? kept
110
+ : null;
111
+ }
112
+
113
+ /**
114
+ * The campaign this visit arrived from (a `visitCampaign`), or null; reads only. `sessionId` is the
115
+ * session kept on the device (the host's, or the SDK's cookie), null while it lives in memory.
116
+ *
117
+ * @param {string|null|undefined} propertyId
118
+ * @param {{ sessionId?: string|null, now?: number }} [options]
119
+ */
120
+ export function readVisitCampaign(
121
+ propertyId,
122
+ { sessionId = null, now = Date.now() } = {},
123
+ ) {
124
+ return live(stored(keyOf(propertyId)), sessionId, now)?.tags ?? null;
125
+ }
126
+
127
+ /**
128
+ * A page or an event of the visit: the kept campaign stays, active from `now`; a visit with none
129
+ * takes `landing`, this page's tags, when it has any. Answers the visit's campaign. `sessionId` is
130
+ * compared with the kept one; it is stored only with `keepSession` (analytics granted).
131
+ *
132
+ * @param {string|null|undefined} propertyId
133
+ * @param {Record<string, string>|null} landing This page's `visitCampaign`, or null.
134
+ * @param {{ sessionId?: string|null, keepSession?: boolean, now?: number }} [options]
135
+ */
136
+ export function keepVisitCampaign(
137
+ propertyId,
138
+ landing,
139
+ { sessionId = null, keepSession = false, now = Date.now() } = {},
140
+ ) {
141
+ const key = keyOf(propertyId);
142
+ const raw = stored(key);
143
+ const kept = live(raw, sessionId, now);
144
+ const tags = kept?.tags ?? landing ?? null;
145
+ // A session id is added only while analytics is granted, as the session cookie is; one kept
146
+ // under an earlier grant stays while consent is pending, so a new session still ends the visit.
147
+ // Withdrawal clears the store (`clearVisitCampaign`).
148
+ if (tags)
149
+ put(key, {
150
+ tags,
151
+ at: now,
152
+ sid: (keepSession ? sessionId : null) ?? kept?.sid ?? null,
153
+ });
154
+ else if (raw) put(key, null);
155
+ return tags;
156
+ }
157
+
158
+ /** Forget every property's visit campaign on this origin: a sign-out, or consent withdrawn. */
159
+ export function clearVisitCampaign() {
160
+ memory.clear();
161
+ try {
162
+ const prefix = `${storageName('visitCampaign')}:`;
163
+ for (const key of Object.keys(localStorage))
164
+ if (key.startsWith(prefix)) localStorage.removeItem(key);
165
+ } catch {
166
+ /* nothing was kept */
167
+ }
168
+ }