@thg-agentic/hyper-personalisation-web 3.24.0 → 3.25.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.
@@ -56,6 +56,8 @@ const COPY = {
56
56
  claimed: 'This email address has already claimed its code.',
57
57
  copied: 'Copied',
58
58
  thanks: 'Thanks, you’re signed up',
59
+ retry: 'You’re signed up, but we couldn’t load your prize. Please try again.',
60
+ unclaimed: 'We couldn’t load your prize right now.',
59
61
  unavailable: 'Not available right now.',
60
62
  won: 'You won',
61
63
  };
@@ -404,6 +406,9 @@ export function mountForm(o) {
404
406
  warmed: o.warmed ?? new Set(),
405
407
  warming: false,
406
408
  owed: false,
409
+ // Whether the last claim failed (said where the code would be), and how many spins it failed.
410
+ unclaimed: false,
411
+ misses: 0,
407
412
  submitted: false,
408
413
  pow: null,
409
414
  wheel: null,
@@ -651,9 +656,19 @@ export function mountForm(o) {
651
656
  function fillCodes() {
652
657
  for (const box of state.codes) {
653
658
  box.replaceChildren();
654
- if (!state.claim) continue;
659
+ if (!state.claim) {
660
+ if (state.unclaimed) box.append(el('p', '', COPY.unclaimed));
661
+ continue;
662
+ }
655
663
  if (!state.claim.code) {
656
- box.append(el('p', '', state.claim.held ? COPY.claimed : COPY.code));
664
+ // A code still to come only when every pool was out (`fallback`); a prize with no pool
665
+ // ("Try again") gives none, so it says its own words and nothing about a code.
666
+ const said = state.claim.held
667
+ ? COPY.claimed
668
+ : state.claim.fallback === true
669
+ ? COPY.code
670
+ : state.claim.prize?.label;
671
+ if (said) box.append(el('p', '', said));
657
672
  continue;
658
673
  }
659
674
  // The prize the code is for, beside it: "You won!" alone does not say what.
@@ -1206,6 +1221,7 @@ export function mountForm(o) {
1206
1221
  if (channel === 'email')
1207
1222
  state.signedUp = { email: value, email_receipt: state.receipt };
1208
1223
  state.submitted = true;
1224
+ // Subscribed and done with this form, whatever its claim does: a shopper is never asked twice.
1209
1225
  counters.submitted(form.slot);
1210
1226
  engaged('submit', step.id);
1211
1227
  // A form with prizes claims its code now, whether or not the shopper goes on to spin, so the
@@ -1246,27 +1262,36 @@ export function mountForm(o) {
1246
1262
  * The creative's step `id`, else the SDK's own plain one: a thanks with no prize (`fallback`); a
1247
1263
  * code this browser claimed on another form, with that code (`held`); or, for an address another
1248
1264
  * sign-up claimed with (`held`, no code), a welcome back. That code is never shown here: the
1249
- * sign-up hands it to the destinations again, so the inbox has it when one carries it.
1265
+ * sign-up hands it to the destinations again, so the inbox has it when one carries it. Or a claim
1266
+ * that kept failing (`unclaimed`): the sign-up stands and the prize could not be loaded, so it
1267
+ * thanks the shopper and says so, as Klaviyo's and Privy's wheels do, rather than an error.
1250
1268
  */
1251
1269
  function ownStep(id) {
1252
1270
  if (creative.steps.some((s) => s.id === id)) return id;
1271
+ const shop = { t: 'button', label: COPY.shop, action: 'close' };
1253
1272
  const blocks =
1254
- id === 'fallback' || state.claim?.code
1273
+ id === 'unclaimed'
1255
1274
  ? [
1256
- {
1257
- t: 'heading',
1258
- text: id === 'fallback' ? COPY.thanks : COPY.yours,
1259
- },
1260
- { t: 'code' },
1275
+ { t: 'heading', text: COPY.thanks },
1276
+ { t: 'text', text: COPY.unclaimed },
1277
+ shop,
1261
1278
  ]
1262
- : [
1263
- { t: 'heading', text: COPY.held },
1264
- {
1265
- t: 'text',
1266
- text: form.code_relayed === true ? COPY.resent : COPY.inbox,
1267
- },
1268
- { t: 'button', label: COPY.shop, action: 'close' },
1269
- ];
1279
+ : id === 'fallback' || state.claim?.code
1280
+ ? [
1281
+ {
1282
+ t: 'heading',
1283
+ text: id === 'fallback' ? COPY.thanks : COPY.yours,
1284
+ },
1285
+ { t: 'code' },
1286
+ ]
1287
+ : [
1288
+ { t: 'heading', text: COPY.held },
1289
+ {
1290
+ t: 'text',
1291
+ text: form.code_relayed === true ? COPY.resent : COPY.inbox,
1292
+ },
1293
+ shop,
1294
+ ];
1270
1295
  creative.steps.push({ id, blocks });
1271
1296
  return id;
1272
1297
  }
@@ -1284,6 +1309,7 @@ export function mountForm(o) {
1284
1309
  function claim(retries = CLAIM_RETRY_MS) {
1285
1310
  const receipt = state.receipt;
1286
1311
  state.claiming ??= (async () => {
1312
+ state.unclaimed = false;
1287
1313
  let res;
1288
1314
  for (const ms of [0, ...retries]) {
1289
1315
  if (ms) await new Promise((r) => setTimeout(r, ms));
@@ -1302,6 +1328,8 @@ export function mountForm(o) {
1302
1328
  // the page; any other failure is final until a spin asks again.
1303
1329
  if (!retries.length && res.status === 503) return claim();
1304
1330
  settled(null);
1331
+ state.unclaimed = true;
1332
+ if (!state.closed) fillCodes();
1305
1333
  return null;
1306
1334
  }
1307
1335
  // The browser that claimed the code proved it is the shopper's when it won it: a held claim
@@ -1322,9 +1350,12 @@ export function mountForm(o) {
1322
1350
  busy(true);
1323
1351
  const won = await claim();
1324
1352
  if (state.closed) return;
1353
+ // The sign-up stands whatever the claim did: a failed one is offered once more, then the
1354
+ // shopper is thanked and told the prize could not be loaded, never left on an error.
1325
1355
  if (!won) {
1326
1356
  busy(false);
1327
- return say(COPY.failed);
1357
+ state.misses += 1;
1358
+ return state.misses < 2 ? say(COPY.retry) : show(ownStep('unclaimed'));
1328
1359
  }
1329
1360
  const fallback = won.fallback === true && !won.code;
1330
1361
  // A code the address claimed on another form is that form's prize: the wheel lands only on
@@ -145,7 +145,7 @@ hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--ln)}
145
145
  .hd{display:none}
146
146
  .uc h2{text-transform:uppercase;font-weight:800;letter-spacing:-.01em;line-height:1.02}
147
147
  .uc .btn:not(.no),.uc .tb{text-transform:uppercase;letter-spacing:.06em}
148
- .sheet,.bar,.teaser{position:fixed;inset:auto;z-index:2147483646;display:flex;flex-direction:column}
148
+ .sheet,.bar,.teaser{position:fixed;inset:auto;height:auto;z-index:2147483646;display:flex;flex-direction:column}
149
149
  .sheet{right:24px;bottom:24px;width:min(400px,calc(100% - 32px));max-height:calc(100% - 48px)}
150
150
  .sheet.p-bottom-left{right:auto;left:24px}
151
151
  .sheet.p-bottom{right:auto;left:50%;translate:-50% 0}
@@ -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;
@@ -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}`,
@@ -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',
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.0';
1
+ export const VERSION = '3.25.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -165,8 +165,9 @@ export interface BootstrappedClient extends AnalyticsClient {
165
165
  }
166
166
 
167
167
  /**
168
- * The client, or null when the key is not enabled, the settings cannot be fetched on a first
169
- * visit, or the options are invalid. Never rejects. Idempotent per page.
168
+ * The client, or null when the key is not enabled or the options are invalid. Settings that cannot
169
+ * be fetched on a first visit start it on built-in, collect-only defaults (3.25). Never rejects.
170
+ * Idempotent per page.
170
171
  */
171
172
  export declare function bootstrap(
172
173
  config: HpConfig,
package/types/hp.d.ts CHANGED
@@ -35,8 +35,9 @@ interface HpTrack {
35
35
  /**
36
36
  * The page's client, callable from anywhere: calls made before `bootstrap` has started it wait and
37
37
  * replay in order, keeping the messageId and time they were made with; on a page that turns out to
38
- * be off they are dropped. A page that navigates first, or never reaches the API, carries them to the
39
- * next page, which sends them under its own consent (3.9, 3.9.1). Nothing is returned (but by
38
+ * be off they are dropped. A page that navigates first carries them to the next page, which sends
39
+ * them under its own consent (3.9); one that cannot reach the API starts on built-in settings and
40
+ * sends them itself (3.25). Nothing is returned (but by
40
41
  * `assignmentDecisionId` and `recommendationContext`), nothing is awaited, and nothing ever throws
41
42
  * into the caller.
42
43
  */
package/types/index.d.ts CHANGED
@@ -119,7 +119,8 @@ export interface AnalyticsOptions extends AnalyticsContextSources {
119
119
  apiHost?: string;
120
120
  /** Default 'web'. */
121
121
  writeKey?: string;
122
- propertyId: string | number;
122
+ /** null: none on the wire, and the ingest stamps the key's (a key scoped to one property). */
123
+ propertyId: string | number | null;
123
124
  channel?: string | null;
124
125
  market?: string | null;
125
126
  environment?: string | null;
@@ -200,7 +201,7 @@ export interface AnalyticsOptions extends AnalyticsContextSources {
200
201
  mintAnonymousId?: boolean;
201
202
  /**
202
203
  * Strict consent: carry events held for an unanswered CMP across a navigation, in sessionStorage
203
- * per property (`thg_hp_held`), so a redirect before the answer (a sign-in) does not lose them.
204
+ * per write key (`thg_hp_held`), so a redirect before the answer (a sign-in) does not lose them.
204
205
  * They leave only on a grant, on whichever page it comes, and are deleted on a refusal or after
205
206
  * 30 minutes. This writes event data to the device before the visitor has answered: enable it
206
207
  * only where your consent policy allows that. With bootstrap it also lets `hp` carry calls made
@@ -466,7 +467,7 @@ export interface StorageNames {
466
467
  decisions?: string;
467
468
  /** localStorage. Default 'thg_hp_settings'. */
468
469
  settings?: string;
469
- /** sessionStorage, with holdAcrossPages. Default 'thg_hp_held'. */
470
+ /** sessionStorage, per write key, with holdAcrossPages. Default 'thg_hp_held'. */
470
471
  held?: string;
471
472
  /** sessionStorage: `hp` calls carried to the next page (3.9). Default 'thg_hp_carried'. */
472
473
  carried?: string;