@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.
- package/LICENSE +21 -0
- package/README.md +825 -2
- package/dist/hp.min.js +3 -0
- package/dist/snippet.min.js +1 -0
- package/package.json +83 -3
- package/src/assignment.js +75 -0
- package/src/auto/attributes.js +16 -0
- package/src/auto/exposures.js +45 -0
- package/src/auto/lists.js +136 -0
- package/src/auto/promotions.js +104 -0
- package/src/auto/rails.js +217 -0
- package/src/auto/viewability.js +150 -0
- package/src/auto.js +13 -0
- package/src/bootstrap.js +476 -0
- package/src/carry.js +74 -0
- package/src/conformance.js +530 -0
- package/src/consent-gate.js +324 -0
- package/src/consent.js +604 -0
- package/src/context.js +439 -0
- package/src/datalayer.js +411 -0
- package/src/decisions.js +90 -0
- package/src/device-storage.js +201 -0
- package/src/experiments.js +220 -0
- package/src/ga4.js +124 -0
- package/src/hash.js +138 -0
- package/src/hp.js +135 -0
- package/src/index.js +818 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/segment.js +375 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +163 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +146 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +211 -0
- package/src/shape.js +111 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +159 -0
- package/src/transport.js +436 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +154 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +680 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +610 -0
- package/types/segment.d.ts +68 -0
package/src/consent.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent adapters: one small object per CMP, so the SDK never has to know which one a site runs.
|
|
3
|
+
*
|
|
4
|
+
* { name, read(): ConsentState | null, subscribe(onChange): () => void, observesChanges?: boolean }
|
|
5
|
+
*
|
|
6
|
+
* `read()` returns null while the CMP has not answered. A ConsentState carries exactly the five
|
|
7
|
+
* purposes of schemas/common/consent-state.json, each true, false or null ("never asked").
|
|
8
|
+
*
|
|
9
|
+
* `observesChanges: true` promises that `subscribe` reports every change by itself. Without it the
|
|
10
|
+
* consent gate keeps re-reading the adapter every couple of seconds after the first answer, so a
|
|
11
|
+
* withdrawal is honoured within seconds even where nothing announces it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getCookie } from './context.js';
|
|
15
|
+
import { DEFAULT_STORAGE_NAMES, storageName } from './storage-scope.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The first-party cookie that carries the consent decision to server producers, unless the host
|
|
19
|
+
* names another: `storageNames.consent` on the SDK, `name` on forwardedConsent. Both sides of one
|
|
20
|
+
* domain must agree on it.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_CONSENT_COOKIE = DEFAULT_STORAGE_NAMES.consent;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Who decided whether an event may be collected: context.consent.basis (spec 1.5, ADR 0019).
|
|
26
|
+
* `visitor` is the visitor's own answer through a CMP, a banner or the forwarded cookie, where null
|
|
27
|
+
* is unanswered. `host` is the site's own decision, where a null purpose is no statement.
|
|
28
|
+
*/
|
|
29
|
+
export const CONSENT_BASIS = Object.freeze({
|
|
30
|
+
visitor: 'visitor',
|
|
31
|
+
host: 'host',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* context.consent with no adapter: the host has settled the lawful basis itself, and says so, so a
|
|
36
|
+
* warehouse can tell it from a visitor who has not answered.
|
|
37
|
+
*/
|
|
38
|
+
export const hostConsent = () => ({ basis: CONSENT_BASIS.host });
|
|
39
|
+
|
|
40
|
+
/** The request header that carries consent to the serving API, in the cookie's serialisation. */
|
|
41
|
+
export const CONSENT_HEADER = 'hp-consent';
|
|
42
|
+
|
|
43
|
+
export const CONSENT_PURPOSES = [
|
|
44
|
+
'analytics',
|
|
45
|
+
'marketing',
|
|
46
|
+
'personalisation',
|
|
47
|
+
'ad_user_data',
|
|
48
|
+
'ad_personalization',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const COOKIE_KEYS = {
|
|
52
|
+
analytics: 'a',
|
|
53
|
+
marketing: 'm',
|
|
54
|
+
personalisation: 'p',
|
|
55
|
+
ad_user_data: 'aud',
|
|
56
|
+
ad_personalization: 'adp',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const hasWindow = () => typeof window !== 'undefined';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A cookie can change without any event a cookie-based adapter could hear, except through the
|
|
63
|
+
* Cookie Store API — which Firefox and Safari do not implement. Where it is missing, such an adapter
|
|
64
|
+
* cannot promise to observe a change, and the consent gate re-reads it instead.
|
|
65
|
+
*/
|
|
66
|
+
const cookieStoreAvailable = () =>
|
|
67
|
+
hasWindow() && typeof window.cookieStore?.addEventListener === 'function';
|
|
68
|
+
|
|
69
|
+
const tri = (value) => (value === true ? true : value === false ? false : null);
|
|
70
|
+
|
|
71
|
+
/** Coerce anything state-shaped to the five schema keys. Unknown keys never reach the wire. */
|
|
72
|
+
export function normaliseConsentState(raw) {
|
|
73
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
74
|
+
const state = {};
|
|
75
|
+
for (const purpose of CONSENT_PURPOSES) state[purpose] = tri(raw[purpose]);
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sameConsentState(a, b) {
|
|
80
|
+
if (!a || !b) return a === b;
|
|
81
|
+
return CONSENT_PURPOSES.every((purpose) => a[purpose] === b[purpose]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `thg_hp_consent` cookie value: `a=1&m=0&p=1&aud=0&adp=0`, fixed key order, unknown purposes
|
|
86
|
+
* omitted. Byte-for-byte what analytics-java's ConsentCookie.format writes.
|
|
87
|
+
*/
|
|
88
|
+
export function formatConsentCookie(state) {
|
|
89
|
+
if (!state) return '';
|
|
90
|
+
const pairs = [];
|
|
91
|
+
for (const purpose of CONSENT_PURPOSES) {
|
|
92
|
+
const value = state[purpose];
|
|
93
|
+
if (value === true || value === false)
|
|
94
|
+
pairs.push(`${COOKIE_KEYS[purpose]}=${value ? 1 : 0}`);
|
|
95
|
+
}
|
|
96
|
+
return pairs.join('&');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Headers for a request to the serving API (recommendations, decisions, audiences, social proof):
|
|
101
|
+
* `hp-consent: a=1&p=0`, exactly the cookie's value. Serving personalises only when `p=1` is
|
|
102
|
+
* present, so an unanswered CMP sends an empty value and gets the non-personalised response.
|
|
103
|
+
*/
|
|
104
|
+
export function servingHeaders(state) {
|
|
105
|
+
return {
|
|
106
|
+
[CONSENT_HEADER]: formatConsentCookie(normaliseConsentState(state)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Mirrors ConsentCookie.parse: never throws, a key seen twice with different values is unknown. */
|
|
111
|
+
export function parseConsentCookie(value) {
|
|
112
|
+
const state = normaliseConsentState({});
|
|
113
|
+
if (value == null) return state;
|
|
114
|
+
let raw = String(value).trim();
|
|
115
|
+
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"'))
|
|
116
|
+
raw = raw.slice(1, -1);
|
|
117
|
+
if (raw.includes('%')) {
|
|
118
|
+
try {
|
|
119
|
+
raw = decodeURIComponent(raw.replace(/\+/g, ' '));
|
|
120
|
+
} catch {
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const byKey = Object.fromEntries(
|
|
125
|
+
Object.entries(COOKIE_KEYS).map(([purpose, key]) => [key, purpose]),
|
|
126
|
+
);
|
|
127
|
+
const conflicted = new Set();
|
|
128
|
+
for (const pair of raw.split('&')) {
|
|
129
|
+
const eq = pair.indexOf('=');
|
|
130
|
+
if (eq <= 0) continue;
|
|
131
|
+
const purpose = byKey[pair.slice(0, eq).trim()];
|
|
132
|
+
const flag = { 1: true, 0: false }[pair.slice(eq + 1).trim()] ?? null;
|
|
133
|
+
if (!purpose || flag === null || conflicted.has(purpose)) continue;
|
|
134
|
+
if (state[purpose] === null) state[purpose] = flag;
|
|
135
|
+
else if (state[purpose] !== flag) {
|
|
136
|
+
state[purpose] = null;
|
|
137
|
+
conflicted.add(purpose);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return state;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The adapter, reporting only the purposes the property asks for (`hp.consent.purposes` in its
|
|
145
|
+
* settings). A purpose it does not ask for is reported unanswered, which strict mode never treats as
|
|
146
|
+
* granted — so neither the event, nor the cookie, nor `hp-consent` claims a grant the property never
|
|
147
|
+
* sought. Analytics is always reported: it is what collection is gated on.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} adapter
|
|
150
|
+
* @param {Partial<Record<string, boolean>>|null} purposes Absent: every purpose is reported.
|
|
151
|
+
*/
|
|
152
|
+
export function reportingPurposes(adapter, purposes) {
|
|
153
|
+
if (!purposes) return adapter;
|
|
154
|
+
const reported = (state) => {
|
|
155
|
+
if (!state) return state;
|
|
156
|
+
const out = { ...state };
|
|
157
|
+
for (const purpose of CONSENT_PURPOSES) {
|
|
158
|
+
if (purpose !== 'analytics' && purposes[purpose] !== true)
|
|
159
|
+
out[purpose] = null;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
name: adapter.name,
|
|
165
|
+
basis: adapter.basis,
|
|
166
|
+
read: () => reported(adapter.read()),
|
|
167
|
+
subscribe: (onChange) =>
|
|
168
|
+
adapter.subscribe((state) => onChange(reported(state))),
|
|
169
|
+
get observesChanges() {
|
|
170
|
+
return adapter.observesChanges;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** ad_user_data and ad_personalization follow marketing unless a mapping names them. */
|
|
176
|
+
function withAdSignals(state, explicit = {}) {
|
|
177
|
+
return {
|
|
178
|
+
...state,
|
|
179
|
+
ad_user_data: explicit.ad_user_data ?? state.marketing,
|
|
180
|
+
ad_personalization: explicit.ad_personalization ?? state.marketing,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A subscription whose callbacks stop the moment it is torn down, whatever the CMP does after. */
|
|
185
|
+
function subscription(read, onChange) {
|
|
186
|
+
let active = true;
|
|
187
|
+
const notify = () => {
|
|
188
|
+
if (!active) return;
|
|
189
|
+
try {
|
|
190
|
+
const state = read();
|
|
191
|
+
if (state) onChange(state);
|
|
192
|
+
} catch {
|
|
193
|
+
/* a CMP callback must never throw into the CMP */
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
return { notify, stop: () => (active = false) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------------------------
|
|
200
|
+
// Didomi
|
|
201
|
+
// ---------------------------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const DIDOMI_PURPOSES = {
|
|
204
|
+
analytics: [
|
|
205
|
+
'analytics',
|
|
206
|
+
'measure_content_performance',
|
|
207
|
+
'audience_measurement',
|
|
208
|
+
],
|
|
209
|
+
marketing: [
|
|
210
|
+
'marketing',
|
|
211
|
+
'targeting',
|
|
212
|
+
'advertising',
|
|
213
|
+
'select_personalized_ads',
|
|
214
|
+
'create_ads_profile',
|
|
215
|
+
],
|
|
216
|
+
personalisation: [
|
|
217
|
+
'personalisation',
|
|
218
|
+
'personalization',
|
|
219
|
+
'select_personalized_content',
|
|
220
|
+
'create_content_profile',
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Didomi custom purposes carry a per-notice suffix (`analytics-BNjaanaW`), so an id matches itself
|
|
226
|
+
* or itself plus `-<suffix>`.
|
|
227
|
+
*/
|
|
228
|
+
function didomiPurpose(purposes, ids) {
|
|
229
|
+
if (!ids?.length) return null;
|
|
230
|
+
let seen = false;
|
|
231
|
+
for (const [key, value] of Object.entries(purposes)) {
|
|
232
|
+
const id = String(value?.id ?? key).toLowerCase();
|
|
233
|
+
const matches = ids.some((want) => {
|
|
234
|
+
const w = String(want).toLowerCase();
|
|
235
|
+
return id === w || id.startsWith(`${w}-`);
|
|
236
|
+
});
|
|
237
|
+
if (!matches) continue;
|
|
238
|
+
seen = true;
|
|
239
|
+
if (value?.enabled === true) return true;
|
|
240
|
+
}
|
|
241
|
+
return seen ? false : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {{ purposes?: Partial<Record<'analytics'|'marketing'|'personalisation'|'ad_user_data'|'ad_personalization', string[]>> }} [options]
|
|
246
|
+
*/
|
|
247
|
+
export function didomiConsent(options = {}) {
|
|
248
|
+
const ids = { ...DIDOMI_PURPOSES, ...options.purposes };
|
|
249
|
+
|
|
250
|
+
function read() {
|
|
251
|
+
const didomi = hasWindow() ? window.Didomi : undefined;
|
|
252
|
+
if (!didomi?.getCurrentUserStatus) return null;
|
|
253
|
+
// Didomi fills `purposes` with defaults before the visitor answers, so a status that still has
|
|
254
|
+
// to be collected (or is partial) is "not answered", never a refusal.
|
|
255
|
+
if (didomi.shouldUserStatusBeCollected?.() !== false) return null;
|
|
256
|
+
if (didomi.isUserStatusPartial?.() === true) return null;
|
|
257
|
+
const purposes = didomi.getCurrentUserStatus()?.purposes;
|
|
258
|
+
if (!purposes || typeof purposes !== 'object') return null;
|
|
259
|
+
|
|
260
|
+
const pick = (purpose) => didomiPurpose(purposes, ids[purpose]);
|
|
261
|
+
return withAdSignals(
|
|
262
|
+
{
|
|
263
|
+
analytics: pick('analytics'),
|
|
264
|
+
marketing: pick('marketing'),
|
|
265
|
+
personalisation: pick('personalisation'),
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
ad_user_data: pick('ad_user_data'),
|
|
269
|
+
ad_personalization: pick('ad_personalization'),
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
name: 'didomi',
|
|
276
|
+
read,
|
|
277
|
+
observesChanges: true,
|
|
278
|
+
subscribe(onChange) {
|
|
279
|
+
if (!hasWindow()) return () => {};
|
|
280
|
+
const sub = subscription(read, onChange);
|
|
281
|
+
const listener = { event: 'consent.changed', listener: sub.notify };
|
|
282
|
+
window.didomiEventListeners = window.didomiEventListeners || [];
|
|
283
|
+
window.didomiEventListeners.push(listener);
|
|
284
|
+
window.didomiOnReady = window.didomiOnReady || [];
|
|
285
|
+
// Once the SDK is up, pushes to its queues may no longer be read, so register directly too.
|
|
286
|
+
// Duplicate notifications are harmless: the SDK only acts on a state that differs.
|
|
287
|
+
window.didomiOnReady.push((didomi) => {
|
|
288
|
+
sub.notify();
|
|
289
|
+
(didomi ?? window.Didomi)?.on?.('consent.changed', sub.notify);
|
|
290
|
+
});
|
|
291
|
+
window.Didomi?.on?.('consent.changed', sub.notify);
|
|
292
|
+
return sub.stop;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------------------------
|
|
298
|
+
// OneTrust
|
|
299
|
+
// ---------------------------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function parseFormCookie(value) {
|
|
302
|
+
const out = {};
|
|
303
|
+
if (!value) return out;
|
|
304
|
+
for (const pair of String(value).split('&')) {
|
|
305
|
+
const eq = pair.indexOf('=');
|
|
306
|
+
if (eq <= 0) continue;
|
|
307
|
+
let v = pair.slice(eq + 1).replace(/\+/g, ' ');
|
|
308
|
+
try {
|
|
309
|
+
v = decodeURIComponent(v);
|
|
310
|
+
} catch {
|
|
311
|
+
/* keep the raw value */
|
|
312
|
+
}
|
|
313
|
+
out[pair.slice(0, eq)] = v;
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* @param {{
|
|
320
|
+
* groups?: Partial<Record<'analytics'|'marketing'|'personalisation'|'ad_user_data'|'ad_personalization', string>>,
|
|
321
|
+
* impliedConsent?: boolean,
|
|
322
|
+
* }} [options]
|
|
323
|
+
*
|
|
324
|
+
* Answered means the visitor closed the banner (`OptanonAlertBoxClosed`, `IsAlertBoxClosed()`) or
|
|
325
|
+
* interacted with it (`interactionCount > 0`): OneTrust writes default groups before anyone clicks.
|
|
326
|
+
* `impliedConsent: true` accepts OneTrust's groups as soon as it has loaded, for sites whose
|
|
327
|
+
* geolocation rule is notice-only / opt-out; never set it for an opt-in (GDPR) audience.
|
|
328
|
+
* Global Privacy Control forces marketing and both ad signals to false: GPC is an opt-out of sale
|
|
329
|
+
* and sharing, which is what those purposes gate. It does not touch analytics.
|
|
330
|
+
*/
|
|
331
|
+
export function oneTrustConsent(options = {}) {
|
|
332
|
+
const groups = {
|
|
333
|
+
analytics: 'C0002',
|
|
334
|
+
personalisation: 'C0003',
|
|
335
|
+
marketing: 'C0004',
|
|
336
|
+
...options.groups,
|
|
337
|
+
};
|
|
338
|
+
const impliedConsent = options.impliedConsent === true;
|
|
339
|
+
|
|
340
|
+
function activeGroups() {
|
|
341
|
+
const live = hasWindow() ? window.OnetrustActiveGroups : undefined;
|
|
342
|
+
if (typeof live === 'string' && live.length) {
|
|
343
|
+
const active = new Set(
|
|
344
|
+
live
|
|
345
|
+
.split(',')
|
|
346
|
+
.map((g) => g.trim())
|
|
347
|
+
.filter(Boolean),
|
|
348
|
+
);
|
|
349
|
+
return (id) => active.has(id);
|
|
350
|
+
}
|
|
351
|
+
const cookie = parseFormCookie(getCookie('OptanonConsent'));
|
|
352
|
+
if (!cookie.groups) return null;
|
|
353
|
+
const flags = {};
|
|
354
|
+
for (const entry of cookie.groups.split(',')) {
|
|
355
|
+
const [id, flag] = entry.split(':');
|
|
356
|
+
if (id?.trim())
|
|
357
|
+
flags[id.trim()] = flag === '1' ? true : flag === '0' ? false : null;
|
|
358
|
+
}
|
|
359
|
+
return (id) => flags[id] ?? null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function answered() {
|
|
363
|
+
if (getCookie('OptanonAlertBoxClosed')) return true;
|
|
364
|
+
try {
|
|
365
|
+
if (hasWindow() && window.OneTrust?.IsAlertBoxClosed?.() === true)
|
|
366
|
+
return true;
|
|
367
|
+
} catch {
|
|
368
|
+
/* fall through to the cookie */
|
|
369
|
+
}
|
|
370
|
+
const count = Number.parseInt(
|
|
371
|
+
parseFormCookie(getCookie('OptanonConsent')).interactionCount,
|
|
372
|
+
10,
|
|
373
|
+
);
|
|
374
|
+
if (count > 0) return true;
|
|
375
|
+
return (
|
|
376
|
+
impliedConsent &&
|
|
377
|
+
hasWindow() &&
|
|
378
|
+
(Boolean(window.OneTrust) ||
|
|
379
|
+
typeof window.OnetrustActiveGroups === 'string')
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function read() {
|
|
384
|
+
if (!answered()) return null;
|
|
385
|
+
const isActive = activeGroups();
|
|
386
|
+
if (!isActive) return null;
|
|
387
|
+
const group = (purpose) =>
|
|
388
|
+
groups[purpose] ? isActive(groups[purpose]) : null;
|
|
389
|
+
const state = withAdSignals(
|
|
390
|
+
{
|
|
391
|
+
analytics: group('analytics'),
|
|
392
|
+
marketing: group('marketing'),
|
|
393
|
+
personalisation: group('personalisation'),
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
ad_user_data: group('ad_user_data'),
|
|
397
|
+
ad_personalization: group('ad_personalization'),
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
const cookie = parseFormCookie(getCookie('OptanonConsent'));
|
|
401
|
+
const gpc =
|
|
402
|
+
(typeof navigator !== 'undefined' &&
|
|
403
|
+
navigator.globalPrivacyControl === true) ||
|
|
404
|
+
cookie.isGpcEnabled === '1';
|
|
405
|
+
if (gpc) {
|
|
406
|
+
state.marketing = false;
|
|
407
|
+
state.ad_user_data = false;
|
|
408
|
+
state.ad_personalization = false;
|
|
409
|
+
}
|
|
410
|
+
return state;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
name: 'onetrust',
|
|
415
|
+
read,
|
|
416
|
+
observesChanges: true,
|
|
417
|
+
subscribe(onChange) {
|
|
418
|
+
if (!hasWindow()) return () => {};
|
|
419
|
+
const sub = subscription(read, onChange);
|
|
420
|
+
let hookedConsentChanged = false;
|
|
421
|
+
const hookOneTrust = () => {
|
|
422
|
+
if (hookedConsentChanged || !window.OneTrust?.OnConsentChanged) return;
|
|
423
|
+
hookedConsentChanged = true;
|
|
424
|
+
window.OneTrust.OnConsentChanged(sub.notify);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
window.addEventListener('OneTrustGroupsUpdated', sub.notify);
|
|
428
|
+
const previous = window.OptanonWrapper;
|
|
429
|
+
window.OptanonWrapper = function optanonWrapper(...args) {
|
|
430
|
+
try {
|
|
431
|
+
if (typeof previous === 'function') previous.apply(this, args);
|
|
432
|
+
} finally {
|
|
433
|
+
hookOneTrust();
|
|
434
|
+
sub.notify();
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
hookOneTrust();
|
|
438
|
+
|
|
439
|
+
return () => {
|
|
440
|
+
sub.stop();
|
|
441
|
+
window.removeEventListener('OneTrustGroupsUpdated', sub.notify);
|
|
442
|
+
};
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------------------------
|
|
448
|
+
// A site's own banner, read from a cookie
|
|
449
|
+
// ---------------------------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
/** Comma-separated accepted category ids. Empty means nothing was recorded, so unknown. */
|
|
452
|
+
function parseCategoryList(raw) {
|
|
453
|
+
const ids = String(raw)
|
|
454
|
+
.split(',')
|
|
455
|
+
.map((id) => id.trim())
|
|
456
|
+
.filter(Boolean);
|
|
457
|
+
return ids.length ? new Set(ids) : null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A banner that records the visitor's choice in a first-party cookie.
|
|
462
|
+
*
|
|
463
|
+
* @param {object} options
|
|
464
|
+
* @param {string} options.name Cookie name.
|
|
465
|
+
* @param {Partial<Record<'analytics'|'marketing'|'personalisation'|'ad_user_data'|'ad_personalization', string>>} options.categories
|
|
466
|
+
* The cookie's category id for each purpose; `analytics` is required.
|
|
467
|
+
* @param {(raw: string) => Set<string>|null} [options.parse] Accepted category ids from the
|
|
468
|
+
* (URL-decoded) cookie value, or null when it cannot be read. Default: comma-separated ids.
|
|
469
|
+
* @param {string} [options.refusedValue] A literal value meaning "rejected everything".
|
|
470
|
+
* @param {string} [options.source] context.consent.source. Default 'native'.
|
|
471
|
+
* @param {string} [options.changeEvent] Window event the banner dispatches after writing the cookie.
|
|
472
|
+
* Default 'thg_hp_consent_change'.
|
|
473
|
+
*
|
|
474
|
+
* No cookie means the banner has not been answered. Cookie Store `change` events are also watched
|
|
475
|
+
* where the browser has them.
|
|
476
|
+
*/
|
|
477
|
+
export function cookieConsent(options) {
|
|
478
|
+
const {
|
|
479
|
+
name,
|
|
480
|
+
categories,
|
|
481
|
+
parse = parseCategoryList,
|
|
482
|
+
refusedValue,
|
|
483
|
+
source = 'native',
|
|
484
|
+
changeEvent = 'thg_hp_consent_change',
|
|
485
|
+
} = options ?? {};
|
|
486
|
+
if (typeof name !== 'string' || name === '') {
|
|
487
|
+
throw new Error(
|
|
488
|
+
'cookieConsent: `name` (the cookie your banner writes) is required',
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (!categories || typeof categories.analytics !== 'string') {
|
|
492
|
+
throw new Error(
|
|
493
|
+
'cookieConsent: `categories.analytics` (the category id that grants analytics) is required',
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function read() {
|
|
498
|
+
const raw = getCookie(name);
|
|
499
|
+
if (raw == null) return null;
|
|
500
|
+
let accepted;
|
|
501
|
+
if (refusedValue !== undefined && raw === refusedValue)
|
|
502
|
+
accepted = new Set();
|
|
503
|
+
else {
|
|
504
|
+
try {
|
|
505
|
+
accepted = parse(raw);
|
|
506
|
+
} catch {
|
|
507
|
+
accepted = null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (!(accepted instanceof Set)) return null;
|
|
511
|
+
const has = (purpose) =>
|
|
512
|
+
categories[purpose] ? accepted.has(categories[purpose]) : null;
|
|
513
|
+
return withAdSignals(
|
|
514
|
+
{
|
|
515
|
+
analytics: has('analytics'),
|
|
516
|
+
marketing: has('marketing'),
|
|
517
|
+
personalisation: has('personalisation'),
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
ad_user_data: has('ad_user_data'),
|
|
521
|
+
ad_personalization: has('ad_personalization'),
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
name: source,
|
|
528
|
+
read,
|
|
529
|
+
get observesChanges() {
|
|
530
|
+
return cookieStoreAvailable();
|
|
531
|
+
},
|
|
532
|
+
subscribe(onChange) {
|
|
533
|
+
if (!hasWindow()) return () => {};
|
|
534
|
+
const sub = subscription(read, onChange);
|
|
535
|
+
const onCookieChange = (event) => {
|
|
536
|
+
const touched = [...(event.changed ?? []), ...(event.deleted ?? [])];
|
|
537
|
+
if (touched.some((cookie) => cookie?.name === name)) sub.notify();
|
|
538
|
+
};
|
|
539
|
+
window.addEventListener(changeEvent, sub.notify);
|
|
540
|
+
window.cookieStore?.addEventListener?.('change', onCookieChange);
|
|
541
|
+
return () => {
|
|
542
|
+
sub.stop();
|
|
543
|
+
window.removeEventListener(changeEvent, sub.notify);
|
|
544
|
+
window.cookieStore?.removeEventListener?.('change', onCookieChange);
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------------------------
|
|
551
|
+
// No CMP
|
|
552
|
+
// ---------------------------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* For pages that run without the CMP — a separate checkout app, a subdomain, a microsite — on the
|
|
556
|
+
* same registrable domain as a page that has one. The SDK on the CMP page records the visitor's
|
|
557
|
+
* decision in the `thg_hp_consent` cookie (set `consentCookieDomain` there so it reaches here); this
|
|
558
|
+
* reads it back. No cookie, or no analytics answer in it, means not answered, never granted.
|
|
559
|
+
*/
|
|
560
|
+
export function forwardedConsent(options = {}) {
|
|
561
|
+
const name = options.name ?? storageName('consent');
|
|
562
|
+
const read = () => {
|
|
563
|
+
const raw = getCookie(name);
|
|
564
|
+
if (raw == null) return null;
|
|
565
|
+
const state = parseConsentCookie(raw);
|
|
566
|
+
return state.analytics == null ? null : state;
|
|
567
|
+
};
|
|
568
|
+
return {
|
|
569
|
+
name: 'forwarded',
|
|
570
|
+
read,
|
|
571
|
+
get observesChanges() {
|
|
572
|
+
return cookieStoreAvailable();
|
|
573
|
+
},
|
|
574
|
+
subscribe(onChange) {
|
|
575
|
+
if (!hasWindow()) return () => {};
|
|
576
|
+
const sub = subscription(read, onChange);
|
|
577
|
+
const onCookieChange = (event) => {
|
|
578
|
+
const touched = [...(event.changed ?? []), ...(event.deleted ?? [])];
|
|
579
|
+
if (touched.some((cookie) => cookie?.name === name)) sub.notify();
|
|
580
|
+
};
|
|
581
|
+
window.cookieStore?.addEventListener?.('change', onCookieChange);
|
|
582
|
+
return () => {
|
|
583
|
+
sub.stop();
|
|
584
|
+
window.cookieStore?.removeEventListener?.('change', onCookieChange);
|
|
585
|
+
};
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* For a site with no CMP where the integrator has decided the lawful basis, e.g.
|
|
592
|
+
* `staticConsent({ analytics: true })`. An explicit opt-in: never a default. The state is the host's,
|
|
593
|
+
* not a visitor's answer, so its events carry `basis: 'host'`.
|
|
594
|
+
*/
|
|
595
|
+
export function staticConsent(state) {
|
|
596
|
+
const fixed = normaliseConsentState(state);
|
|
597
|
+
return {
|
|
598
|
+
name: 'static',
|
|
599
|
+
basis: CONSENT_BASIS.host,
|
|
600
|
+
read: () => (fixed ? { ...fixed } : null),
|
|
601
|
+
observesChanges: true,
|
|
602
|
+
subscribe: () => () => {},
|
|
603
|
+
};
|
|
604
|
+
}
|