@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.1

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +825 -2
  3. package/dist/hp.min.js +3 -0
  4. package/dist/snippet.min.js +1 -0
  5. package/package.json +83 -3
  6. package/src/assignment.js +75 -0
  7. package/src/auto/attributes.js +16 -0
  8. package/src/auto/exposures.js +45 -0
  9. package/src/auto/lists.js +136 -0
  10. package/src/auto/promotions.js +104 -0
  11. package/src/auto/rails.js +217 -0
  12. package/src/auto/viewability.js +150 -0
  13. package/src/auto.js +13 -0
  14. package/src/bootstrap.js +476 -0
  15. package/src/carry.js +74 -0
  16. package/src/conformance.js +530 -0
  17. package/src/consent-gate.js +324 -0
  18. package/src/consent.js +604 -0
  19. package/src/context.js +439 -0
  20. package/src/datalayer.js +411 -0
  21. package/src/decisions.js +90 -0
  22. package/src/device-storage.js +201 -0
  23. package/src/experiments.js +220 -0
  24. package/src/ga4.js +124 -0
  25. package/src/hash.js +138 -0
  26. package/src/hp.js +135 -0
  27. package/src/index.js +818 -0
  28. package/src/preview.js +44 -0
  29. package/src/recommendation-context.js +101 -0
  30. package/src/segment.js +375 -0
  31. package/src/session/category.js +40 -0
  32. package/src/session/record.js +163 -0
  33. package/src/session/rules.js +195 -0
  34. package/src/session/signals.js +146 -0
  35. package/src/session-id.js +62 -0
  36. package/src/session.js +28 -0
  37. package/src/settings.js +211 -0
  38. package/src/shape.js +111 -0
  39. package/src/social-proof.js +139 -0
  40. package/src/spec-version.js +4 -0
  41. package/src/storage-scope.js +159 -0
  42. package/src/transport.js +436 -0
  43. package/src/version.js +2 -0
  44. package/types/auto.d.ts +51 -0
  45. package/types/bootstrap.d.ts +154 -0
  46. package/types/consent.d.ts +126 -0
  47. package/types/datalayer.d.ts +76 -0
  48. package/types/events.d.ts +680 -0
  49. package/types/hp.d.ts +64 -0
  50. package/types/index.d.ts +610 -0
  51. package/types/segment.d.ts +68 -0
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Strict consent, independent of how events are delivered.
3
+ *
4
+ * Both delivery paths — the built-in transport and the analytics-next plugins — hand every event
5
+ * to one of these, so "what may be sent, and when" has a single implementation.
6
+ *
7
+ * pending CMP has not answered: items are held (in memory, or with a `store` in
8
+ * sessionStorage too, so they survive a navigation)
9
+ * granted analytics === true: items pass
10
+ * denied analytics === false: items are dropped
11
+ * unknown timed out: still held, and the CMP is read more slowly until it answers
12
+ *
13
+ * Strict is the only mode. An unanswered CMP never becomes a grant, however long it takes; what is
14
+ * held leaves only on a grant, and is dropped on a refusal or when the page ends. A visitor who
15
+ * reads the banner for a minute before accepting keeps the page view that brought them here.
16
+ */
17
+
18
+ import {
19
+ CONSENT_BASIS,
20
+ CONSENT_PURPOSES,
21
+ formatConsentCookie,
22
+ normaliseConsentState,
23
+ parseConsentCookie,
24
+ sameConsentState,
25
+ } from './consent.js';
26
+ import { getCookie } from './context.js';
27
+ import { storageName } from './storage-scope.js';
28
+
29
+ const CONSENT_COOKIE_MAX_AGE = 180 * 24 * 60 * 60;
30
+ const POLL_MS = 100;
31
+ const SLOW_POLL_MS = 1000;
32
+ /**
33
+ * How often an answered page re-reads an adapter that cannot report changes itself: cookie-based
34
+ * adapters rely on the Cookie Store API, which Firefox and Safari lack, so a mid-page withdrawal is
35
+ * noticed within seconds. Only while the page is visible.
36
+ */
37
+ const RECHECK_MS = 2000;
38
+ /** What a page that never gets an answer may hold, so memory stays bounded; later items drop. */
39
+ const MAX_HELD = 500;
40
+
41
+ // Never the reason a server-rendering or test process stays alive; a no-op in browsers.
42
+ const unref = (handle) => {
43
+ handle?.unref?.();
44
+ return handle;
45
+ };
46
+
47
+ export function consentAction(next, previous) {
48
+ if (previous?.analytics === true && next.analytics === false)
49
+ return 'withdrawn';
50
+ const known = CONSENT_PURPOSES.map((purpose) => next[purpose]).filter(
51
+ (v) => v !== null,
52
+ );
53
+ if (known.length && known.every(Boolean)) return 'accept_all';
54
+ if (known.length && known.every((v) => !v)) return 'reject_all';
55
+ return 'custom';
56
+ }
57
+
58
+ export function writeConsentCookie(
59
+ state,
60
+ domain,
61
+ name = storageName('consent'),
62
+ ) {
63
+ if (typeof document === 'undefined') return;
64
+ try {
65
+ const secure =
66
+ typeof location !== 'undefined' && location.protocol === 'https:'
67
+ ? '; Secure'
68
+ : '';
69
+ const scope = domain ? `; Domain=${domain}` : '';
70
+ document.cookie =
71
+ `${name}=${formatConsentCookie(state)}; Path=/; Max-Age=${CONSENT_COOKIE_MAX_AGE}; SameSite=Lax` +
72
+ scope +
73
+ secure;
74
+ } catch {
75
+ /* a sandboxed document without cookie access */
76
+ }
77
+ }
78
+
79
+ /** The last decision this device reported, so a stored answer re-read on each page is not a change. */
80
+ function previousFromCookie(name) {
81
+ const state = parseConsentCookie(getCookie(name));
82
+ return CONSENT_PURPOSES.some((purpose) => state[purpose] !== null)
83
+ ? state
84
+ : null;
85
+ }
86
+
87
+ /**
88
+ * @param {object} options
89
+ * @param {{ name: string, read: () => object|null, subscribe?: (fn: (state: object) => void) => () => void }} options.adapter
90
+ * @param {number} [options.timeoutMs]
91
+ * @param {string} [options.cookieDomain]
92
+ * @param {string} [options.cookieName] Defaults to storageName('consent'), `thg_hp_consent`.
93
+ * @param {(item: any) => void} options.release A held item may now be sent.
94
+ * @param {(item: any) => void} [options.discard] A held item will never be sent.
95
+ * @param {(update: { action: string, state: object, withdrawal: boolean }) => void} options.announce
96
+ * Record a Consent Updated. Called only when analytics is granted, or for the withdrawal itself.
97
+ * @param {(status: string) => void} [options.onStatus]
98
+ * @param {boolean} [options.debug]
99
+ * @param {{ read: () => any[], write: (items: any[]) => void }} [options.store]
100
+ * Carry held items across pages (`holdAcrossPages`). Read on start, so a grant on the next page
101
+ * releases what the previous one held; cleared on a grant or a refusal, never at unload.
102
+ */
103
+ export function createConsentGate(options) {
104
+ const {
105
+ adapter,
106
+ timeoutMs = 5000,
107
+ cookieDomain = null,
108
+ cookieName = storageName('consent'),
109
+ release,
110
+ announce,
111
+ } = options;
112
+ const discard = options.discard ?? (() => {});
113
+
114
+ let status = 'pending';
115
+ let state = null;
116
+ let poll = null;
117
+ let timer = null;
118
+ let recheck = null;
119
+ let stopped = false;
120
+ let unsubscribe = null;
121
+ const held = [];
122
+
123
+ const setStatus = (next) => {
124
+ status = next;
125
+ options.onStatus?.(next);
126
+ };
127
+
128
+ function read() {
129
+ try {
130
+ return normaliseConsentState(adapter.read());
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function stopWatching() {
137
+ if (poll) clearInterval(poll);
138
+ if (timer) clearTimeout(timer);
139
+ poll = null;
140
+ timer = null;
141
+ }
142
+
143
+ const hasDocument = () =>
144
+ typeof window !== 'undefined' && typeof document !== 'undefined';
145
+
146
+ /** An adapter that reports every change itself (a CMP event API) needs no re-reading. */
147
+ const observesChanges = () => {
148
+ try {
149
+ return (
150
+ typeof adapter.subscribe === 'function' &&
151
+ adapter.observesChanges === true
152
+ );
153
+ } catch {
154
+ return false;
155
+ }
156
+ };
157
+
158
+ function pauseRecheck() {
159
+ if (recheck) clearInterval(recheck);
160
+ recheck = null;
161
+ }
162
+
163
+ /** After the first answer: keep re-reading, slowly, while the page is visible. */
164
+ function resumeRecheck() {
165
+ if (stopped || recheck || state === null || observesChanges()) return;
166
+ if (hasDocument() && document.visibilityState === 'hidden') return;
167
+ recheck = unref(setInterval(() => apply(read()), RECHECK_MS));
168
+ }
169
+
170
+ const awaitingAnswer = () => status === 'pending' || status === 'unknown';
171
+ const store = options.store ?? null;
172
+ const releaseHeld = () => {
173
+ const items = held.splice(0);
174
+ store?.write([]);
175
+ for (const item of items) release(item);
176
+ };
177
+ const discardHeld = () => {
178
+ const items = held.splice(0);
179
+ store?.write([]);
180
+ for (const item of items) discard(item);
181
+ };
182
+
183
+ function apply(raw) {
184
+ const next = normaliseConsentState(raw);
185
+ // Gating is on analytics alone; a state that does not answer it is still "not answered".
186
+ if (!next || next.analytics === null) return;
187
+ const first = state === null;
188
+ const previous = first ? previousFromCookie(cookieName) : state;
189
+ if (!first && sameConsentState(previous, next)) return;
190
+
191
+ stopWatching();
192
+ state = next;
193
+ resumeRecheck();
194
+ writeConsentCookie(next, cookieDomain, cookieName);
195
+ const changed = !sameConsentState(previous, next);
196
+
197
+ if (next.analytics) {
198
+ setStatus('granted');
199
+ releaseHeld();
200
+ if (changed)
201
+ announce({
202
+ action: consentAction(next, previous),
203
+ state: next,
204
+ withdrawal: false,
205
+ });
206
+ return;
207
+ }
208
+
209
+ discardHeld();
210
+ // A withdrawal is recorded, then nothing more: the raw table is where a refusal is proven to
211
+ // have been received, and the warehouse's consent filter keeps it out of every model.
212
+ if (previous?.analytics === true)
213
+ announce({ action: 'withdrawn', state: next, withdrawal: true });
214
+ setStatus('denied');
215
+ }
216
+
217
+ function timedOut() {
218
+ timer = null;
219
+ if (status !== 'pending') return;
220
+ setStatus('unknown');
221
+ // Keep holding and keep listening, more slowly: a visitor who answers the banner a minute in
222
+ // still counts, landing page included.
223
+ if (poll) clearInterval(poll);
224
+ poll = unref(setInterval(() => apply(read()), SLOW_POLL_MS));
225
+ }
226
+
227
+ /**
228
+ * Unload before an answer: nothing unconsented leaves.
229
+ *
230
+ * @param {{ type?: string, persisted?: boolean }} [event] The DOM event that hid the page.
231
+ *
232
+ * Only the end of the page discards: a hidden tab or a pagehide into the back/forward cache can
233
+ * come back and be accepted. Held events are in memory only, so keeping them risks nothing.
234
+ */
235
+ function onHidden(event) {
236
+ if (!awaitingAnswer()) return;
237
+ // With a store, the stored copy is what the next page answers for: the end of this one
238
+ // discards nothing.
239
+ if (store) return;
240
+ if (event?.type !== 'visibilitychange' && !event?.persisted) discardHeld();
241
+ }
242
+
243
+ const onVisibility = (event) => {
244
+ if (document.visibilityState === 'hidden') {
245
+ pauseRecheck();
246
+ onHidden(event);
247
+ } else {
248
+ resumeRecheck();
249
+ }
250
+ };
251
+
252
+ const onPageHide = (event) => {
253
+ pauseRecheck();
254
+ onHidden(event);
255
+ };
256
+
257
+ const onPageShow = () => resumeRecheck();
258
+
259
+ return {
260
+ get status() {
261
+ return status;
262
+ },
263
+ get state() {
264
+ return state ? { ...state } : null;
265
+ },
266
+ /** context.consent for an event collected now. An adapter is the visitor's answer unless it says otherwise. */
267
+ stamp() {
268
+ return state
269
+ ? {
270
+ ...state,
271
+ source: adapter.name ?? null,
272
+ basis: adapter.basis ?? CONSENT_BASIS.visitor,
273
+ }
274
+ : null;
275
+ },
276
+ admits() {
277
+ return status === 'granted';
278
+ },
279
+ /** Hold an item until the CMP answers. Returns false (and discards) once it has answered. */
280
+ hold(item) {
281
+ if (!awaitingAnswer() || held.length >= MAX_HELD) {
282
+ discard(item);
283
+ return false;
284
+ }
285
+ held.push(item);
286
+ store?.write(held);
287
+ return true;
288
+ },
289
+ /** Items unload would lose; the caller's own unload path flushes after this. */
290
+ onHidden,
291
+ start() {
292
+ stopped = false;
293
+ try {
294
+ unsubscribe = adapter.subscribe?.((next) => apply(next)) ?? null;
295
+ } catch (err) {
296
+ if (options.debug)
297
+ console.warn('[hp] consent adapter subscribe failed', err);
298
+ }
299
+ if (hasDocument()) {
300
+ window.addEventListener('pagehide', onPageHide);
301
+ window.addEventListener('pageshow', onPageShow);
302
+ document.addEventListener('visibilitychange', onVisibility);
303
+ }
304
+ // What an earlier page held for an answer it never got, oldest first.
305
+ if (store && held.length === 0)
306
+ held.push(...store.read().slice(-MAX_HELD));
307
+ apply(read());
308
+ if (state) return;
309
+ poll = unref(setInterval(() => apply(read()), POLL_MS));
310
+ timer = unref(setTimeout(timedOut, timeoutMs));
311
+ },
312
+ stop() {
313
+ stopped = true;
314
+ stopWatching();
315
+ pauseRecheck();
316
+ if (typeof unsubscribe === 'function') unsubscribe();
317
+ if (hasDocument()) {
318
+ window.removeEventListener('pagehide', onPageHide);
319
+ window.removeEventListener('pageshow', onPageShow);
320
+ document.removeEventListener('visibilitychange', onVisibility);
321
+ }
322
+ },
323
+ };
324
+ }