@consenti/ui 0.1.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.
- package/README.md +888 -0
- package/dist/angular.d.cts +481 -0
- package/dist/angular.d.ts +481 -0
- package/dist/angular.js +74 -0
- package/dist/angular.js.map +1 -0
- package/dist/angular.mjs +53 -0
- package/dist/angular.mjs.map +1 -0
- package/dist/chunk-ARALKXMB.mjs +20 -0
- package/dist/chunk-ARALKXMB.mjs.map +1 -0
- package/dist/hmac-N6H7F2K2.mjs +26 -0
- package/dist/hmac-N6H7F2K2.mjs.map +1 -0
- package/dist/http-VHOGZ6ZJ.mjs +7 -0
- package/dist/http-VHOGZ6ZJ.mjs.map +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +770 -0
- package/dist/index.mjs +2221 -0
- package/dist/index.mjs.map +1 -0
- package/dist/index.umd.js +2305 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/react.d.cts +452 -0
- package/dist/react.d.ts +452 -0
- package/dist/react.js +61 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +40 -0
- package/dist/react.mjs.map +1 -0
- package/dist/style.d.ts +2 -0
- package/dist/testing.d.cts +109 -0
- package/dist/testing.d.ts +109 -0
- package/dist/testing.js +95 -0
- package/dist/testing.js.map +1 -0
- package/dist/testing.mjs +74 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/vue.d.cts +446 -0
- package/dist/vue.d.ts +446 -0
- package/dist/vue.js +73 -0
- package/dist/vue.js.map +1 -0
- package/dist/vue.mjs +52 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +97 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
httpRequest
|
|
3
|
+
} from "./chunk-ARALKXMB.mjs";
|
|
4
|
+
|
|
5
|
+
// src/styles/consenti-css.ts
|
|
6
|
+
var CONSENTI_CSS = `:root{--consenti-color-bg: #ffffff;--consenti-color-text: #1a2e4a;--consenti-color-primary: #1565c0;--consenti-color-primary-text: #ffffff;--consenti-color-secondary: #f0f4f8;--consenti-color-secondary-text: #1a2e4a;--consenti-color-border: #dbe4ee;--consenti-color-overlay: rgba(26, 52, 96, 0.55);--consenti-font-family: system-ui, -apple-system, sans-serif;--consenti-font-size-base: 14px;--consenti-font-size-heading: 16px;--consenti-font-weight-heading: 600;--consenti-line-height: 1.5;--consenti-spacing-sm: 8px;--consenti-spacing-md: 16px;--consenti-spacing-lg: 24px;--consenti-border-radius: 8px;--consenti-border-radius-btn: 4px;--consenti-shadow: 0 4px 24px rgba(21, 101, 192, 0.14);--consenti-color-accent: #d32f2f;--consenti-color-accent-text: #ffffff;--consenti-toggle-bg-on: #43a047;--consenti-toggle-bg-off: #c8d6e8;--consenti-toggle-knob: #ffffff;--consenti-toggle-width: 44px;--consenti-toggle-height: 24px;--consenti-z-banner: 9999;--consenti-z-overlay: 9998;--consenti-z-modal: 10000}.consenti-root--dark{--consenti-color-bg: #1e2535;--consenti-color-text: #e2e8f0;--consenti-color-primary: #4a9eff;--consenti-color-primary-text: #ffffff;--consenti-color-secondary: #2a3447;--consenti-color-secondary-text: #cbd5e1;--consenti-color-border: #374151;--consenti-color-overlay: rgba(0, 0, 0, 0.65);--consenti-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);--consenti-toggle-bg-off: #4b5563}.consenti-overlay{position:fixed;inset:0;background:var(--consenti-color-overlay);z-index:var(--consenti-z-overlay)}.consenti-btn{display:inline-flex;align-items:center;justify-content:center;padding:var(--consenti-spacing-sm) var(--consenti-spacing-md);border:none;border-radius:var(--consenti-border-radius-btn);font-family:var(--consenti-font-family);font-size:var(--consenti-font-size-base);font-weight:500;line-height:1;cursor:pointer;text-decoration:none;transition:opacity .15s ease}.consenti-btn:focus-visible{outline:2px solid var(--consenti-color-primary);outline-offset:2px}.consenti-btn:hover{opacity:.9}.consenti-btn--primary{background:var(--consenti-color-primary);color:var(--consenti-color-primary-text)}.consenti-btn--secondary{background:var(--consenti-color-secondary);color:var(--consenti-color-secondary-text)}.consenti-btn--text{background:rgba(0,0,0,0);color:var(--consenti-color-primary);padding-left:0;padding-right:0;text-decoration:underline}.consenti-btn--accent{background:var(--consenti-color-accent);color:var(--consenti-color-accent-text)}.consenti-btn--close{background:rgba(0,0,0,0);color:var(--consenti-color-text);font-size:20px;line-height:1;padding:4px 8px;border-radius:50%}.consenti-btn--close:hover{background:var(--consenti-color-secondary);opacity:1}.consenti-banner{position:fixed;left:0;right:0;z-index:var(--consenti-z-banner);background:var(--consenti-color-bg);color:var(--consenti-color-text);font-family:var(--consenti-font-family);font-size:var(--consenti-font-size-base);line-height:var(--consenti-line-height);box-shadow:var(--consenti-shadow);padding:var(--consenti-spacing-lg);display:flex;flex-wrap:wrap;align-items:center;gap:var(--consenti-spacing-md)}.consenti-banner--top{top:0;border-bottom:1px solid var(--consenti-color-border)}@media(prefers-reduced-motion: no-preference){.consenti-banner--top{animation:consenti-slide-down .2s ease-out}}.consenti-banner--bottom{bottom:0;border-top:1px solid var(--consenti-color-border)}@media(prefers-reduced-motion: no-preference){.consenti-banner--bottom{animation:consenti-slide-up .2s ease-out}}.consenti-banner--middle{top:50%;transform:translateY(-50%);left:50%;right:auto;transform:translate(-50%, -50%);width:min(600px,90vw);border-radius:var(--consenti-border-radius)}.consenti-banner--left-bottom{bottom:var(--consenti-spacing-lg);left:var(--consenti-spacing-lg);right:auto;width:min(400px,90vw);border-radius:var(--consenti-border-radius)}.consenti-banner--right-bottom{bottom:var(--consenti-spacing-lg);right:var(--consenti-spacing-lg);left:auto;width:min(400px,90vw);border-radius:var(--consenti-border-radius)}.consenti-banner__text-container{flex:1;min-width:200px}.consenti-banner__heading{font-size:var(--consenti-font-size-heading);font-weight:var(--consenti-font-weight-heading);margin:0 0 var(--consenti-spacing-sm);width:100%}.consenti-banner__text{margin:0}.consenti-banner__links{display:flex;flex-wrap:wrap;gap:var(--consenti-spacing-sm);margin-top:var(--consenti-spacing-sm)}.consenti-banner__link{font-size:.85em;text-decoration:underline;text-underline-offset:2px}.consenti-banner__controls{position:absolute;top:var(--consenti-spacing-sm);right:var(--consenti-spacing-sm);display:flex;align-items:center;gap:4px}.consenti-banner__buttons{display:flex;flex-wrap:wrap;gap:var(--consenti-spacing-sm);align-items:center}.consenti-banner--top .consenti-banner__buttons,.consenti-banner--bottom .consenti-banner__buttons{margin-left:2vw}@keyframes consenti-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes consenti-slide-down{from{transform:translateY(-100%)}to{transform:translateY(0)}}.consenti-modal{position:fixed;inset:0;z-index:var(--consenti-z-modal);display:flex;font-family:var(--consenti-font-family);font-size:var(--consenti-font-size-base);color:var(--consenti-color-text);line-height:var(--consenti-line-height)}.consenti-modal--passthrough{pointer-events:none}.consenti-modal--passthrough .consenti-modal__container{pointer-events:auto}.consenti-modal--center{align-items:center;justify-content:center}.consenti-modal--left{align-items:stretch;justify-content:flex-start}.consenti-modal--left .consenti-modal__container{border-radius:0 var(--consenti-border-radius) var(--consenti-border-radius) 0;height:100%;max-height:100vh}.consenti-modal--right{align-items:stretch;justify-content:flex-end}.consenti-modal--right .consenti-modal__container{border-radius:0;height:100%;max-height:100vh}.consenti-modal__overlay{position:absolute;inset:0;background:var(--consenti-color-overlay)}.consenti-modal__container{position:relative;z-index:1;background:var(--consenti-color-bg);border-radius:var(--consenti-border-radius);box-shadow:var(--consenti-shadow);width:min(560px,90vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden}@media(prefers-reduced-motion: no-preference){.consenti-modal__container{animation:consenti-fade-in .15s ease-out}}.consenti-modal__header{display:flex;align-items:center;justify-content:space-between;padding:var(--consenti-spacing-md) var(--consenti-spacing-lg);border-bottom:1px solid var(--consenti-color-border);flex-shrink:0}.consenti-modal__heading{font-size:var(--consenti-font-size-heading);font-weight:var(--consenti-font-weight-heading);margin:0;flex:1}.consenti-modal__header-controls{display:flex;align-items:center;gap:4px;flex-shrink:0}.consenti-modal__close{flex-shrink:0}.consenti-modal__links{display:flex;flex-wrap:wrap;gap:var(--consenti-spacing-sm);padding:var(--consenti-spacing-sm) var(--consenti-spacing-lg)}.consenti-modal__link{font-size:.85em;text-decoration:underline;text-underline-offset:2px}.consenti-modal__subheading{padding:var(--consenti-spacing-sm) var(--consenti-spacing-lg) 0;margin:0;color:#6b7280;font-size:13px}.consenti-modal__text{padding:var(--consenti-spacing-sm) var(--consenti-spacing-lg)}.consenti-modal__body{overflow-y:auto;flex:1;padding:0 var(--consenti-spacing-lg)}.consenti-modal__categories{display:flex;flex-direction:column;gap:var(--consenti-spacing-md);padding:var(--consenti-spacing-md) 0}.consenti-modal__buttons{display:flex;flex-wrap:wrap;gap:var(--consenti-spacing-sm);padding:var(--consenti-spacing-md) var(--consenti-spacing-lg);border-top:1px solid var(--consenti-color-border);flex-shrink:0}.consenti-modal__receipt-option{margin:0 var(--consenti-spacing-lg);padding-top:var(--consenti-spacing-md);border-top:1px solid var(--consenti-color-border);flex-shrink:0}.consenti-modal__receipt-label{display:flex;align-items:center;gap:var(--consenti-spacing-sm);font-size:var(--consenti-font-size-base);cursor:pointer}.consenti-modal__receipt-checkbox{width:16px;height:16px;flex-shrink:0;cursor:pointer}.consenti-modal__receipt-desc{margin:4px 0 0 24px;font-size:12px;color:#6b7280}@keyframes consenti-fade-in{from{opacity:0;transform:scale(0.97)}to{opacity:1;transform:scale(1)}}.consenti-category{padding:var(--consenti-spacing-md) 0;border-bottom:1px solid var(--consenti-color-border)}.consenti-category:last-child{border-bottom:none}.consenti-category--mandatory .consenti-category__toggle{opacity:.6;cursor:default}.consenti-category__header{display:flex;align-items:center;justify-content:space-between;gap:var(--consenti-spacing-md);margin-bottom:var(--consenti-spacing-sm)}.consenti-category__heading{font-size:var(--consenti-font-size-heading);font-weight:var(--consenti-font-weight-heading);margin:0}.consenti-category__text{color:#4b5563;font-size:13px;margin:0}.consenti-category__li-desc{margin:var(--consenti-spacing-sm) 0 0;font-size:12px;color:#6b7280;font-style:italic}.consenti-category__toggle{flex-shrink:0;position:relative;width:var(--consenti-toggle-width);height:var(--consenti-toggle-height);border-radius:calc(var(--consenti-toggle-height)/2);background:var(--consenti-toggle-bg-off);border:none;cursor:pointer;transition:background .2s ease;padding:0}.consenti-category__toggle:focus-visible{outline:2px solid var(--consenti-color-primary);outline-offset:2px}.consenti-category__toggle[aria-checked=true]{background:var(--consenti-toggle-bg-on)}.consenti-category__toggle--mandatory{cursor:default}.consenti-category__toggle-knob{position:absolute;top:2px;left:2px;width:calc(var(--consenti-toggle-height) - 4px);height:calc(var(--consenti-toggle-height) - 4px);border-radius:50%;background:var(--consenti-toggle-knob);transition:transform .2s ease;display:block}[aria-checked=true] .consenti-category__toggle-knob{transform:translateX(calc(var(--consenti-toggle-width) - var(--consenti-toggle-height)))}.consenti-locale-switcher{position:relative;display:inline-flex}.consenti-locale-switcher__btn{font-size:1rem;line-height:1;cursor:pointer}.consenti-locale-switcher__list{position:absolute;top:calc(100% + 4px);right:0;z-index:calc(var(--consenti-z-modal) + 1);background:var(--consenti-color-bg);border:1px solid var(--consenti-color-border);border-radius:var(--consenti-border-radius);box-shadow:var(--consenti-shadow);list-style:none;margin:0;padding:4px 0;min-width:80px}.consenti-locale-switcher__list li{display:block}.consenti-locale-switcher__option{display:block;width:100%;padding:6px 12px;text-align:left;font-size:var(--consenti-font-size-base);color:var(--consenti-color-text);background:none;border:none;cursor:pointer}.consenti-locale-switcher__option:hover{background:var(--consenti-color-secondary)}.consenti-locale-switcher__option--active{font-weight:600;color:var(--consenti-color-primary)}.consenti-locale-switcher--modal .consenti-locale-switcher__list{right:0}`;
|
|
7
|
+
|
|
8
|
+
// src/utils/ssr.ts
|
|
9
|
+
var isClient = () => typeof window !== "undefined" && typeof document !== "undefined";
|
|
10
|
+
var safeWindow = () => isClient() ? window : null;
|
|
11
|
+
var safeNavigator = () => isClient() && typeof navigator !== "undefined" ? navigator : null;
|
|
12
|
+
|
|
13
|
+
// src/utils/broadcast.ts
|
|
14
|
+
var ConsentiChannel = class {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.channel = null;
|
|
17
|
+
}
|
|
18
|
+
/** `true` when `BroadcastChannel` is available in the current environment. */
|
|
19
|
+
get isSupported() {
|
|
20
|
+
return isClient() && typeof BroadcastChannel !== "undefined";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Opens the `BroadcastChannel` and registers a message handler.
|
|
24
|
+
* Silently no-ops if `BroadcastChannel` is unsupported.
|
|
25
|
+
*
|
|
26
|
+
* @param onMessage - Callback invoked with each received `ConsentiMessage`.
|
|
27
|
+
*/
|
|
28
|
+
connect(onMessage) {
|
|
29
|
+
if (!this.isSupported) return;
|
|
30
|
+
this.onMessageCallback = onMessage;
|
|
31
|
+
this.channel = new BroadcastChannel("consenti");
|
|
32
|
+
this.channel.onmessage = (event) => {
|
|
33
|
+
if (event.data?.type) {
|
|
34
|
+
onMessage(event.data);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
this.channel.onmessageerror = () => {
|
|
38
|
+
console.warn("[Consenti] BroadcastChannel message error \u2014 ignoring");
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Posts a message to all other tabs connected to the `'consenti'` channel.
|
|
43
|
+
* The sending tab does not receive its own message.
|
|
44
|
+
*
|
|
45
|
+
* @param message - The `ConsentiMessage` to broadcast.
|
|
46
|
+
*/
|
|
47
|
+
send(message) {
|
|
48
|
+
this.channel?.postMessage(message);
|
|
49
|
+
}
|
|
50
|
+
/** Closes the channel and removes the message callback. */
|
|
51
|
+
disconnect() {
|
|
52
|
+
this.channel?.close();
|
|
53
|
+
this.channel = null;
|
|
54
|
+
this.onMessageCallback = void 0;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/utils/receipt.ts
|
|
59
|
+
function generateReceipt(params) {
|
|
60
|
+
const receipt = {
|
|
61
|
+
version: "1.0",
|
|
62
|
+
issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
63
|
+
visitorId: params.visitorId,
|
|
64
|
+
profileId: params.profileId,
|
|
65
|
+
profileVersion: params.profileVersion,
|
|
66
|
+
locale: params.locale,
|
|
67
|
+
consent: params.consent
|
|
68
|
+
};
|
|
69
|
+
if (params.signature) receipt.signature = params.signature;
|
|
70
|
+
return receipt;
|
|
71
|
+
}
|
|
72
|
+
function downloadReceipt(receipt) {
|
|
73
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
74
|
+
const json = JSON.stringify(receipt, null, 2);
|
|
75
|
+
const blob = new Blob([json], { type: "application/json" });
|
|
76
|
+
const url = URL.createObjectURL(blob);
|
|
77
|
+
const anchor = document.createElement("a");
|
|
78
|
+
anchor.href = url;
|
|
79
|
+
anchor.download = `consenti-receipt-${receipt.issuedAt.slice(0, 10)}.json`;
|
|
80
|
+
anchor.style.display = "none";
|
|
81
|
+
document.body.appendChild(anchor);
|
|
82
|
+
anchor.click();
|
|
83
|
+
document.body.removeChild(anchor);
|
|
84
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/utils/cookie.ts
|
|
88
|
+
function getCookie(name) {
|
|
89
|
+
if (!isClient()) return null;
|
|
90
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escapeRegExp(name)}=([^;]*)`));
|
|
91
|
+
return match ? decodeURIComponent(match[1] ?? "") : null;
|
|
92
|
+
}
|
|
93
|
+
function setCookie(name, value, opts = {}) {
|
|
94
|
+
if (!isClient()) return;
|
|
95
|
+
const secure = opts.secure ?? (typeof window !== "undefined" && typeof window.location !== "undefined" && window.location.protocol === "https:");
|
|
96
|
+
const parts = [
|
|
97
|
+
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
|
98
|
+
`Path=${opts.path ?? "/"}`,
|
|
99
|
+
`SameSite=${opts.sameSite ?? "Lax"}`,
|
|
100
|
+
opts.maxAge != null ? `Max-Age=${opts.maxAge}` : "",
|
|
101
|
+
secure ? "Secure" : "",
|
|
102
|
+
opts.domain ? `Domain=${opts.domain}` : ""
|
|
103
|
+
];
|
|
104
|
+
document.cookie = parts.filter(Boolean).join("; ");
|
|
105
|
+
}
|
|
106
|
+
function deleteCookie(name, opts = {}) {
|
|
107
|
+
setCookie(name, "", { ...opts, maxAge: 0 });
|
|
108
|
+
}
|
|
109
|
+
function escapeRegExp(str) {
|
|
110
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/utils/storage.ts
|
|
114
|
+
var ConsentStorage = class {
|
|
115
|
+
/**
|
|
116
|
+
* @param mode - `'cookie'` (default) or `'localStorage'`. Falls back to `'cookie'` if
|
|
117
|
+
* `localStorage` is requested but unavailable in the current environment.
|
|
118
|
+
*/
|
|
119
|
+
constructor(mode) {
|
|
120
|
+
this.mode = mode;
|
|
121
|
+
if (mode === "localStorage" && isClient() && typeof localStorage === "undefined") {
|
|
122
|
+
console.warn("[Consenti] localStorage is not available, falling back to cookie storage");
|
|
123
|
+
this.mode = "cookie";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Reads a value from storage.
|
|
128
|
+
*
|
|
129
|
+
* @param key - Cookie name or localStorage key.
|
|
130
|
+
* @returns The stored string value, or `null` if absent or SSR context.
|
|
131
|
+
*/
|
|
132
|
+
read(key) {
|
|
133
|
+
if (!isClient()) return null;
|
|
134
|
+
if (this.mode === "localStorage") {
|
|
135
|
+
return localStorage.getItem(key);
|
|
136
|
+
}
|
|
137
|
+
return getCookie(key);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Writes a value to storage.
|
|
141
|
+
*
|
|
142
|
+
* @param key - Cookie name or localStorage key.
|
|
143
|
+
* @param value - String value to persist.
|
|
144
|
+
* @param opts - Cookie attributes (ignored in localStorage mode).
|
|
145
|
+
*/
|
|
146
|
+
write(key, value, opts = {}) {
|
|
147
|
+
if (!isClient()) return;
|
|
148
|
+
if (this.mode === "localStorage") {
|
|
149
|
+
localStorage.setItem(key, value);
|
|
150
|
+
} else {
|
|
151
|
+
setCookie(key, value, opts);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Removes a value from storage.
|
|
156
|
+
*
|
|
157
|
+
* @param key - Cookie name or localStorage key.
|
|
158
|
+
* @param opts - Cookie `path` / `domain` attributes (must match the original write call).
|
|
159
|
+
*/
|
|
160
|
+
remove(key, opts = {}) {
|
|
161
|
+
if (!isClient()) return;
|
|
162
|
+
if (this.mode === "localStorage") {
|
|
163
|
+
localStorage.removeItem(key);
|
|
164
|
+
} else {
|
|
165
|
+
deleteCookie(key, opts);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// src/core/consent-store.ts
|
|
171
|
+
var COOKIE_NAME_PREFIX = "consenti";
|
|
172
|
+
function buildConsentCookieName(userId, profileId) {
|
|
173
|
+
return `${COOKIE_NAME_PREFIX}_${userId}_${profileId}`;
|
|
174
|
+
}
|
|
175
|
+
function buildCookieValue(consent) {
|
|
176
|
+
const epochSeconds = Math.floor(Date.now() / 1e3);
|
|
177
|
+
const pairs = Object.entries(consent).map(([id, status]) => `${id}:${status}`).join("|");
|
|
178
|
+
return `t:${epochSeconds}::${pairs}`;
|
|
179
|
+
}
|
|
180
|
+
function parseConsentCookie(value) {
|
|
181
|
+
const parts = value.split("::");
|
|
182
|
+
if (parts.length < 2) return null;
|
|
183
|
+
const tsPart = parts[0] ?? "";
|
|
184
|
+
let timestamp;
|
|
185
|
+
if (tsPart.startsWith("t:")) {
|
|
186
|
+
const epochStr = tsPart.slice(2);
|
|
187
|
+
const epoch = parseInt(epochStr, 10);
|
|
188
|
+
if (!epochStr || isNaN(epoch)) return null;
|
|
189
|
+
timestamp = new Date(epoch * 1e3).toISOString();
|
|
190
|
+
} else if (tsPart.startsWith("ConsentTimestamp:")) {
|
|
191
|
+
timestamp = tsPart.replace("ConsentTimestamp:", "");
|
|
192
|
+
if (!timestamp) return null;
|
|
193
|
+
} else {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const consentPart = parts[1] ?? "";
|
|
197
|
+
const sigPart = parts[2];
|
|
198
|
+
const consent = {};
|
|
199
|
+
for (const pair of consentPart.split("|")) {
|
|
200
|
+
const colonIdx = pair.indexOf(":");
|
|
201
|
+
if (colonIdx === -1) return null;
|
|
202
|
+
const id = pair.slice(0, colonIdx);
|
|
203
|
+
const status = pair.slice(colonIdx + 1);
|
|
204
|
+
if (!id || status !== "granted" && status !== "denied" && status !== "objected") {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
consent[id] = status;
|
|
208
|
+
}
|
|
209
|
+
const result = { timestamp, consent };
|
|
210
|
+
if (sigPart?.startsWith("sig:")) {
|
|
211
|
+
result.signature = sigPart.slice(4);
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
var ConsentStore = class _ConsentStore {
|
|
216
|
+
/**
|
|
217
|
+
* @param userId - Visitor UUID (from `consenti_uid` cookie).
|
|
218
|
+
* @param profileId - Numeric profile ID.
|
|
219
|
+
* @param storageMode - `'cookie'` (default, cross-subdomain) or `'localStorage'` (origin-scoped).
|
|
220
|
+
* @param cookieDomains - Comma-separated domain list. First entry used as `Domain` attribute.
|
|
221
|
+
*/
|
|
222
|
+
constructor(userId, profileId, storageMode, cookieDomains) {
|
|
223
|
+
this.userId = userId;
|
|
224
|
+
this.profileId = profileId;
|
|
225
|
+
this.cookieDomains = cookieDomains;
|
|
226
|
+
this.cachedConsent = null;
|
|
227
|
+
this.cachedTimestamp = null;
|
|
228
|
+
this.storage = new ConsentStorage(storageMode);
|
|
229
|
+
}
|
|
230
|
+
/** The full cookie / localStorage key for this visitor + profile combination. */
|
|
231
|
+
get cookieName() {
|
|
232
|
+
return buildConsentCookieName(this.userId, this.profileId);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Reads and parses the stored consent record.
|
|
236
|
+
* Updates the in-memory cache on success.
|
|
237
|
+
*
|
|
238
|
+
* @returns The parsed consent or `null` if absent / malformed.
|
|
239
|
+
*/
|
|
240
|
+
read() {
|
|
241
|
+
const raw = this.storage.read(this.cookieName);
|
|
242
|
+
if (!raw) return null;
|
|
243
|
+
const parsed = parseConsentCookie(raw);
|
|
244
|
+
if (parsed) {
|
|
245
|
+
this.cachedConsent = parsed.consent;
|
|
246
|
+
this.cachedTimestamp = parsed.timestamp;
|
|
247
|
+
}
|
|
248
|
+
return parsed;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Reads, parses, and cryptographically verifies the stored consent record.
|
|
252
|
+
*
|
|
253
|
+
* If the cookie has a `::sig:{hmac}` segment, the signature is verified against
|
|
254
|
+
* the raw value using HMAC-SHA256. A mismatch means the cookie was tampered with —
|
|
255
|
+
* the cookie is deleted and `null` is returned so the widget re-prompts for consent.
|
|
256
|
+
*
|
|
257
|
+
* If the cookie has no signature segment, it is treated as valid (backwards compatible
|
|
258
|
+
* with cookies written before `signCookies` was enabled).
|
|
259
|
+
*
|
|
260
|
+
* @param signingKey - The HMAC key (from profile API response or `core.cookieSigningKey`).
|
|
261
|
+
* @returns The parsed consent, or `null` if absent / malformed / signature invalid.
|
|
262
|
+
*/
|
|
263
|
+
async readAndVerify(signingKey) {
|
|
264
|
+
const raw = this.storage.read(this.cookieName);
|
|
265
|
+
if (!raw) return null;
|
|
266
|
+
const parts = raw.split("::");
|
|
267
|
+
if (parts.length < 2) return null;
|
|
268
|
+
const parsed = parseConsentCookie(raw);
|
|
269
|
+
if (!parsed) return null;
|
|
270
|
+
const hasSig = parts.length === 3 && (parts[2] ?? "").startsWith("sig:");
|
|
271
|
+
if (hasSig) {
|
|
272
|
+
const { verifyValue } = await import("./hmac-N6H7F2K2.mjs");
|
|
273
|
+
const valueToVerify = (parts[0] ?? "") + "::" + (parts[1] ?? "");
|
|
274
|
+
const sig = (parts[2] ?? "").slice(4);
|
|
275
|
+
const valid = await verifyValue(valueToVerify, sig, signingKey);
|
|
276
|
+
if (!valid) {
|
|
277
|
+
console.warn("[Consenti] Cookie signature mismatch \u2014 consent cleared and banner will re-appear");
|
|
278
|
+
this.delete();
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
this.cachedConsent = parsed.consent;
|
|
283
|
+
this.cachedTimestamp = parsed.timestamp;
|
|
284
|
+
return parsed;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Writes a consent record to storage.
|
|
288
|
+
*
|
|
289
|
+
* @param consent - The consent map to persist.
|
|
290
|
+
* @param signature - Optional pre-computed HMAC hex string. Use `writeAndSign()` instead
|
|
291
|
+
* if you need signing — it guarantees the signature covers the exact
|
|
292
|
+
* value that gets written (timestamps match).
|
|
293
|
+
*/
|
|
294
|
+
write(consent, signature) {
|
|
295
|
+
let value = buildCookieValue(consent);
|
|
296
|
+
if (signature) {
|
|
297
|
+
value = `${value}::sig:${signature}`;
|
|
298
|
+
}
|
|
299
|
+
const opts = this.buildCookieOpts();
|
|
300
|
+
this.storage.write(this.cookieName, value, opts);
|
|
301
|
+
this.cachedConsent = consent;
|
|
302
|
+
this.cachedTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Builds the cookie value, signs it with HMAC-SHA256 in a single atomic step,
|
|
306
|
+
* and writes the signed value to storage.
|
|
307
|
+
*
|
|
308
|
+
* Using this instead of `write(consent, signature)` ensures the signature
|
|
309
|
+
* covers exactly the value that is written — there is no timestamp drift
|
|
310
|
+
* between building, signing, and writing.
|
|
311
|
+
*
|
|
312
|
+
* @param consent - The consent map to persist.
|
|
313
|
+
* @param signingKey - The HMAC key string.
|
|
314
|
+
* @returns The hex-encoded signature, stored in `this.lastSignature` by the caller.
|
|
315
|
+
*/
|
|
316
|
+
async writeAndSign(consent, signingKey) {
|
|
317
|
+
const { signValue } = await import("./hmac-N6H7F2K2.mjs");
|
|
318
|
+
const rawValue = buildCookieValue(consent);
|
|
319
|
+
const sig = await signValue(rawValue, signingKey);
|
|
320
|
+
const finalValue = `${rawValue}::sig:${sig}`;
|
|
321
|
+
const opts = this.buildCookieOpts();
|
|
322
|
+
this.storage.write(this.cookieName, finalValue, opts);
|
|
323
|
+
this.cachedConsent = consent;
|
|
324
|
+
this.cachedTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
325
|
+
return sig;
|
|
326
|
+
}
|
|
327
|
+
/** Removes the consent record from storage and clears the in-memory cache. */
|
|
328
|
+
delete() {
|
|
329
|
+
const opts = this.buildCookieOpts();
|
|
330
|
+
this.storage.remove(this.cookieName, opts);
|
|
331
|
+
this.cachedConsent = null;
|
|
332
|
+
this.cachedTimestamp = null;
|
|
333
|
+
}
|
|
334
|
+
/** Returns `true` if a valid consent record exists (uses cache when available). */
|
|
335
|
+
hasConsent() {
|
|
336
|
+
if (this.cachedConsent) return true;
|
|
337
|
+
return this.read() !== null;
|
|
338
|
+
}
|
|
339
|
+
/** Returns the stored consent map, or `null` if no consent has been given. */
|
|
340
|
+
getConsent() {
|
|
341
|
+
if (this.cachedConsent) return this.cachedConsent;
|
|
342
|
+
return this.read()?.consent ?? null;
|
|
343
|
+
}
|
|
344
|
+
/** Returns the `Date` of the last consent submission, or `false` if no consent exists. */
|
|
345
|
+
getConsentDate() {
|
|
346
|
+
if (this.cachedTimestamp) return new Date(this.cachedTimestamp);
|
|
347
|
+
const parsed = this.read();
|
|
348
|
+
if (!parsed) return false;
|
|
349
|
+
return new Date(parsed.timestamp);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Updates the in-memory cache without writing to storage.
|
|
353
|
+
* Used by the cross-tab BroadcastChannel handler to keep other tabs in sync.
|
|
354
|
+
*/
|
|
355
|
+
setInMemory(consent) {
|
|
356
|
+
this.cachedConsent = consent;
|
|
357
|
+
this.cachedTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
358
|
+
}
|
|
359
|
+
/** Clears the in-memory cache. Used when consent is deleted from another tab. */
|
|
360
|
+
clearInMemory() {
|
|
361
|
+
this.cachedConsent = null;
|
|
362
|
+
this.cachedTimestamp = null;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Derives the visitor UUID from an existing consent cookie, or generates a new one
|
|
366
|
+
* in memory.
|
|
367
|
+
*
|
|
368
|
+
* No separate identifier cookie is written before consent. Storing a tracking
|
|
369
|
+
* identifier before the user has given consent violates TTDSG §25 (Germany) and
|
|
370
|
+
* equivalent ePrivacy laws in the EU. The UUID is embedded in the consent cookie
|
|
371
|
+
* name and only persisted when consent is first saved (via `write()` / `writeAndSign()`).
|
|
372
|
+
*
|
|
373
|
+
* @param profileId - The active profile ID, used to locate an existing consent cookie.
|
|
374
|
+
* @param storageMode - The configured storage mode (`'cookie'` or `'localStorage'`).
|
|
375
|
+
*/
|
|
376
|
+
static getOrCreateUserId(profileId, storageMode) {
|
|
377
|
+
if (typeof document === "undefined") return crypto.randomUUID();
|
|
378
|
+
const prefix = `${COOKIE_NAME_PREFIX}_`;
|
|
379
|
+
const suffix = `_${profileId}`;
|
|
380
|
+
const all = storageMode === "localStorage" ? _ConsentStore._localStorageKeys() : _ConsentStore._cookieNames();
|
|
381
|
+
for (const name of all) {
|
|
382
|
+
if (name.startsWith(prefix) && name.endsWith(suffix)) {
|
|
383
|
+
const inner = name.slice(prefix.length, name.length - suffix.length);
|
|
384
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(inner)) {
|
|
385
|
+
return inner;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return crypto.randomUUID();
|
|
390
|
+
}
|
|
391
|
+
/** Returns all document.cookie names. */
|
|
392
|
+
static _cookieNames() {
|
|
393
|
+
return document.cookie.split(";").map((c) => c.split("=")[0]?.trim() ?? "");
|
|
394
|
+
}
|
|
395
|
+
/** Returns all localStorage keys (empty array when unavailable). */
|
|
396
|
+
static _localStorageKeys() {
|
|
397
|
+
if (typeof localStorage === "undefined") return [];
|
|
398
|
+
return Object.keys(localStorage);
|
|
399
|
+
}
|
|
400
|
+
buildCookieOpts() {
|
|
401
|
+
const opts = {
|
|
402
|
+
path: "/",
|
|
403
|
+
sameSite: "Lax",
|
|
404
|
+
maxAge: 365 * 24 * 60 * 60
|
|
405
|
+
};
|
|
406
|
+
if (this.cookieDomains) {
|
|
407
|
+
const domain = this.cookieDomains.split(",")[0]?.trim();
|
|
408
|
+
if (domain) opts.domain = domain;
|
|
409
|
+
}
|
|
410
|
+
return opts;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// src/core/event-bus.ts
|
|
415
|
+
var EventBus = class {
|
|
416
|
+
/**
|
|
417
|
+
* @param profileId - Profile ID included in all GTM pushes.
|
|
418
|
+
* @param gtmConfig - Optional GTM integration settings.
|
|
419
|
+
*/
|
|
420
|
+
constructor(profileId, gtmConfig) {
|
|
421
|
+
this.profileId = profileId;
|
|
422
|
+
this.gtmConfig = gtmConfig;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Dispatches a `consenti:*` custom event on `window` and, if GTM is configured,
|
|
426
|
+
* pushes the event to `window.dataLayer`.
|
|
427
|
+
*
|
|
428
|
+
* Silently no-ops in SSR contexts.
|
|
429
|
+
*
|
|
430
|
+
* @param name - The event name.
|
|
431
|
+
* @param detail - Event payload attached to `event.detail` and included in the GTM push.
|
|
432
|
+
*/
|
|
433
|
+
dispatch(name, detail = {}) {
|
|
434
|
+
if (!isClient()) return;
|
|
435
|
+
const event = new CustomEvent(name, { detail, bubbles: true });
|
|
436
|
+
window.dispatchEvent(event);
|
|
437
|
+
this.pushToGTM(name, detail);
|
|
438
|
+
}
|
|
439
|
+
pushToGTM(name, detail) {
|
|
440
|
+
const gtm = this.gtmConfig;
|
|
441
|
+
if (!gtm) return;
|
|
442
|
+
const win = safeWindow();
|
|
443
|
+
if (!win) return;
|
|
444
|
+
const layerName = gtm.dataLayer ?? "dataLayer";
|
|
445
|
+
const layer = win[layerName];
|
|
446
|
+
if (!Array.isArray(layer)) return;
|
|
447
|
+
const shouldPush = !gtm.events || gtm.events.length === 0 || gtm.events.includes(name);
|
|
448
|
+
if (!shouldPush) return;
|
|
449
|
+
layer.push({
|
|
450
|
+
event: name,
|
|
451
|
+
consenti: {
|
|
452
|
+
profileId: this.profileId,
|
|
453
|
+
consent: detail.consent,
|
|
454
|
+
consentId: detail.consentId,
|
|
455
|
+
consentAction: detail.consentAction
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
if (name === "consenti:consentSubmitted" && this.hasGtag(win)) {
|
|
459
|
+
if (gtm.urlPassthrough) {
|
|
460
|
+
layer.push({ url_passthrough: true });
|
|
461
|
+
}
|
|
462
|
+
if (gtm.adsDataRedaction) {
|
|
463
|
+
const adConsentDenied = detail.consent?.["ad_storage"] !== "granted";
|
|
464
|
+
layer.push({ ads_data_redaction: adConsentDenied });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Returns `true` when gtag.js or Google Tag Manager is detected on the page.
|
|
470
|
+
* Used to gate GCM v2 cookieless-ping pushes so they are not emitted when
|
|
471
|
+
* Google services are absent.
|
|
472
|
+
*/
|
|
473
|
+
hasGtag(win) {
|
|
474
|
+
return typeof win["gtag"] === "function" || typeof win["google_tag_manager"] !== "undefined";
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// src/utils/locale.ts
|
|
479
|
+
function resolveLocale(translations, requested, defaultLocale) {
|
|
480
|
+
const base = translations[defaultLocale];
|
|
481
|
+
if (!base) {
|
|
482
|
+
throw new Error(`[Consenti] No translations found for defaultLocale "${defaultLocale}"`);
|
|
483
|
+
}
|
|
484
|
+
if (translations[requested]) {
|
|
485
|
+
return deepMerge(base, translations[requested]);
|
|
486
|
+
}
|
|
487
|
+
const lang = requested.split("-")[0] ?? "";
|
|
488
|
+
if (lang !== requested && translations[lang]) {
|
|
489
|
+
return deepMerge(base, translations[lang]);
|
|
490
|
+
}
|
|
491
|
+
return base;
|
|
492
|
+
}
|
|
493
|
+
function deepMerge(base, override) {
|
|
494
|
+
const result = { ...base };
|
|
495
|
+
for (const key in override) {
|
|
496
|
+
const overrideVal = override[key];
|
|
497
|
+
if (overrideVal === void 0 || overrideVal === null) continue;
|
|
498
|
+
if (typeof overrideVal === "object" && !Array.isArray(overrideVal) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key])) {
|
|
499
|
+
result[key] = deepMerge(result[key], overrideVal);
|
|
500
|
+
} else {
|
|
501
|
+
result[key] = overrideVal;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/core/profile-resolver.ts
|
|
508
|
+
var profileRegistry = /* @__PURE__ */ new Map();
|
|
509
|
+
function registerProfile(id, config) {
|
|
510
|
+
profileRegistry.set(id, config);
|
|
511
|
+
}
|
|
512
|
+
var DEFAULT_PROFILE = {
|
|
513
|
+
id: 0,
|
|
514
|
+
version: 1,
|
|
515
|
+
defaultLocale: "en",
|
|
516
|
+
cookies: [
|
|
517
|
+
{ id: "functionality_storage", mandatory: true },
|
|
518
|
+
{
|
|
519
|
+
id: "analytics_storage",
|
|
520
|
+
listenGpc: true,
|
|
521
|
+
expiry: 365
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: "ad_storage",
|
|
525
|
+
listenGpc: true,
|
|
526
|
+
expiry: 365
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
id: "ad_user_data",
|
|
530
|
+
listenGpc: true,
|
|
531
|
+
expiry: 365
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
id: "ad_personalization",
|
|
535
|
+
listenGpc: true,
|
|
536
|
+
expiry: 365
|
|
537
|
+
}
|
|
538
|
+
],
|
|
539
|
+
mainBanner: {
|
|
540
|
+
position: "bottom",
|
|
541
|
+
heading: "We value your privacy",
|
|
542
|
+
htmlText: "We use cookies to enhance your browsing experience, serve personalised ads or content, and analyse our traffic. By clicking <strong>Accept All</strong> you consent to our use of cookies. You may change your preferences at any time.",
|
|
543
|
+
buttons: [
|
|
544
|
+
{ text: "Accept All", cookies: "*", style: "primary", action: "custom" },
|
|
545
|
+
{ text: "Reject All", cookies: "!", style: "primary", action: "custom" },
|
|
546
|
+
{ text: "Manage Preferences", style: "secondary", action: "manage" }
|
|
547
|
+
]
|
|
548
|
+
},
|
|
549
|
+
preferenceModal: {
|
|
550
|
+
heading: "Cookie Preferences",
|
|
551
|
+
subheading: "Choose which cookies you allow us to use.",
|
|
552
|
+
htmlText: "We use different types of cookies to optimise your experience. You can choose to enable or disable each category below. Necessary cookies cannot be disabled as they are required for core site functionality.",
|
|
553
|
+
showClose: true,
|
|
554
|
+
overlayOpacity: 50,
|
|
555
|
+
categories: [
|
|
556
|
+
{
|
|
557
|
+
id: "cat-necessary",
|
|
558
|
+
heading: "Strictly Necessary",
|
|
559
|
+
htmlText: "These cookies are essential for the website to function correctly. They include authentication, security, and accessibility features. They cannot be disabled.",
|
|
560
|
+
mandatory: true,
|
|
561
|
+
cookies: ["functionality_storage"]
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
id: "cat-analytics",
|
|
565
|
+
heading: "Analytics",
|
|
566
|
+
htmlText: "These cookies help us understand how visitors interact with our website by collecting and reporting information anonymously. This allows us to improve the site over time. (e.g. Google Analytics)",
|
|
567
|
+
cookies: ["analytics_storage"]
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
id: "cat-advertising",
|
|
571
|
+
heading: "Advertising",
|
|
572
|
+
htmlText: "These cookies are used to deliver advertisements more relevant to you and your interests. They are also used to limit the number of times you see an ad and to help measure the effectiveness of advertising campaigns. (e.g. Google Ads, Meta Pixel)",
|
|
573
|
+
cookies: ["ad_storage", "ad_user_data", "ad_personalization"]
|
|
574
|
+
}
|
|
575
|
+
],
|
|
576
|
+
buttons: [
|
|
577
|
+
{ text: "Accept All", cookies: "*", style: "primary", action: "custom" },
|
|
578
|
+
{ text: "Save Preferences", style: "primary", action: "submit" },
|
|
579
|
+
{ text: "Reject All", cookies: "!", style: "primary", action: "custom" }
|
|
580
|
+
]
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
async function resolveProfile(config) {
|
|
584
|
+
const locale = config.core.locale ?? "en";
|
|
585
|
+
const profileId = config.core.profileId ?? 0;
|
|
586
|
+
if (config.api?.enabled) {
|
|
587
|
+
try {
|
|
588
|
+
const base = config.api.baseUrl ?? (typeof window !== "undefined" ? window.location.origin : "");
|
|
589
|
+
const url = `${base}/consenti/api/v1/profiles/${profileId}/${encodeURIComponent(locale)}`;
|
|
590
|
+
const apiProfile = await httpRequest(url, {}, config.api.authToken);
|
|
591
|
+
return {
|
|
592
|
+
id: profileId,
|
|
593
|
+
version: apiProfile.version,
|
|
594
|
+
defaultLocale: apiProfile.defaultLocale,
|
|
595
|
+
locales: apiProfile.locales,
|
|
596
|
+
cookies: apiProfile.cookies,
|
|
597
|
+
mainBanner: apiProfile.mainBanner,
|
|
598
|
+
preferenceModal: apiProfile.preferenceModal,
|
|
599
|
+
...apiProfile.gpcBanner != null ? { gpcBanner: apiProfile.gpcBanner } : {}
|
|
600
|
+
};
|
|
601
|
+
} catch {
|
|
602
|
+
console.warn("[Consenti] API profile fetch failed, falling back to local profile");
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (profileId > 0) {
|
|
606
|
+
const local = profileRegistry.get(profileId);
|
|
607
|
+
if (local) {
|
|
608
|
+
const resolved = resolveLocale(local.translations ?? {}, locale, local.defaultLocale);
|
|
609
|
+
return {
|
|
610
|
+
id: profileId,
|
|
611
|
+
version: 1,
|
|
612
|
+
defaultLocale: local.defaultLocale,
|
|
613
|
+
locales: Object.keys(local.translations ?? {}),
|
|
614
|
+
cookies: local.cookies ?? [],
|
|
615
|
+
mainBanner: resolved.mainBanner,
|
|
616
|
+
...resolved.gpcBanner ? { gpcBanner: resolved.gpcBanner } : {},
|
|
617
|
+
preferenceModal: resolved.preferenceModal,
|
|
618
|
+
...local.darkMode !== void 0 ? { darkMode: local.darkMode } : {}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
throw new Error(
|
|
622
|
+
`[Consenti] Profile ${profileId} not found. Register it with ConsentiProfile or enable the API.`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return DEFAULT_PROFILE;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/core/gpc.ts
|
|
629
|
+
function detectGPC() {
|
|
630
|
+
const nav = safeNavigator();
|
|
631
|
+
if (!nav) return false;
|
|
632
|
+
return "globalPrivacyControl" in nav && nav.globalPrivacyControl === true;
|
|
633
|
+
}
|
|
634
|
+
function applyGPCToConsent(cookies, currentConsent, mode) {
|
|
635
|
+
if (!mode || !detectGPC()) return { ...currentConsent };
|
|
636
|
+
const result = { ...currentConsent };
|
|
637
|
+
for (const cookie of cookies) {
|
|
638
|
+
if (cookie.mandatory) continue;
|
|
639
|
+
if (cookie.listenGpc) {
|
|
640
|
+
result[cookie.id] = cookie.type === "legitimate_interest" ? "objected" : "denied";
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const cookie of cookies) {
|
|
644
|
+
if (cookie.mandatory) result[cookie.id] = "granted";
|
|
645
|
+
}
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/ui/buttons.ts
|
|
650
|
+
function buildButton(btn, handlers) {
|
|
651
|
+
const el = document.createElement("button");
|
|
652
|
+
el.type = "button";
|
|
653
|
+
el.className = `consenti-btn consenti-btn--${btn.style}`;
|
|
654
|
+
el.textContent = btn.text;
|
|
655
|
+
el.addEventListener("click", () => {
|
|
656
|
+
switch (btn.action) {
|
|
657
|
+
case "custom": {
|
|
658
|
+
const cookies = btn.cookies;
|
|
659
|
+
if (cookies === "*") {
|
|
660
|
+
handlers.onGrantAll();
|
|
661
|
+
} else if (cookies === "!") {
|
|
662
|
+
handlers.onDenyAll();
|
|
663
|
+
} else if (Array.isArray(cookies) && cookies.length > 0) {
|
|
664
|
+
handlers.onGrantSpecific(cookies);
|
|
665
|
+
}
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
case "submit":
|
|
669
|
+
handlers.onSubmit();
|
|
670
|
+
break;
|
|
671
|
+
case "manage":
|
|
672
|
+
handlers.onManage();
|
|
673
|
+
break;
|
|
674
|
+
case "close":
|
|
675
|
+
handlers.onClose();
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
return el;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/ui/overlay.ts
|
|
683
|
+
function createOverlay(opacity, onClick) {
|
|
684
|
+
const el = document.createElement("div");
|
|
685
|
+
el.className = "consenti-overlay";
|
|
686
|
+
el.style.opacity = String(opacity / 100);
|
|
687
|
+
el.setAttribute("aria-hidden", "true");
|
|
688
|
+
if (onClick) {
|
|
689
|
+
el.addEventListener("click", onClick);
|
|
690
|
+
}
|
|
691
|
+
return el;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/ui/banner.ts
|
|
695
|
+
var Banner = class {
|
|
696
|
+
constructor() {
|
|
697
|
+
this.el = null;
|
|
698
|
+
this.overlayEl = null;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Constructs the banner DOM from the given config and returns the root element.
|
|
702
|
+
* The element is not yet inserted into the document — the caller appends it to
|
|
703
|
+
* the mount root via `Renderer.mount()`.
|
|
704
|
+
*
|
|
705
|
+
* @param config - Banner layout and content config from the resolved profile.
|
|
706
|
+
* @param handlers - Click handlers wired to each button.
|
|
707
|
+
* @param privacyPolicyUrl - Optional URL appended as a "Privacy Policy" link after the body text.
|
|
708
|
+
* @param locales - All locale codes available on the profile (for the locale switcher).
|
|
709
|
+
* @param activeLocale - The currently active locale code.
|
|
710
|
+
* @param onLocaleSwitch - Callback invoked when the user selects a different locale.
|
|
711
|
+
* @returns The constructed banner `<div>` element.
|
|
712
|
+
*/
|
|
713
|
+
build(config, handlers, privacyPolicyUrl, locales, activeLocale, onLocaleSwitch) {
|
|
714
|
+
const banner = document.createElement("div");
|
|
715
|
+
banner.id = "consenti-banner";
|
|
716
|
+
banner.className = `consenti-banner consenti-banner--${config.position}`;
|
|
717
|
+
banner.setAttribute("role", "region");
|
|
718
|
+
banner.setAttribute("aria-label", "Cookie consent");
|
|
719
|
+
banner.setAttribute("aria-live", "polite");
|
|
720
|
+
const textContainer = document.createElement("div");
|
|
721
|
+
textContainer.className = `consenti-banner__text-container`;
|
|
722
|
+
const linksContainer = document.createElement("div");
|
|
723
|
+
linksContainer.className = "consenti-banner__links";
|
|
724
|
+
if (config.heading) {
|
|
725
|
+
const headingTag = config.headingTag ?? "h2";
|
|
726
|
+
const heading = document.createElement(headingTag);
|
|
727
|
+
heading.id = "consenti-banner-heading";
|
|
728
|
+
heading.className = "consenti-banner__heading";
|
|
729
|
+
heading.textContent = config.heading;
|
|
730
|
+
textContainer.appendChild(heading);
|
|
731
|
+
}
|
|
732
|
+
const text = document.createElement("div");
|
|
733
|
+
text.className = "consenti-banner__text";
|
|
734
|
+
text.innerHTML = config.htmlText;
|
|
735
|
+
if (privacyPolicyUrl) {
|
|
736
|
+
const link = document.createElement("a");
|
|
737
|
+
link.href = privacyPolicyUrl;
|
|
738
|
+
link.className = "consenti-banner__link consenti-banner__link--privacy consenti-btn consenti-btn--text";
|
|
739
|
+
link.textContent = "Privacy Policy";
|
|
740
|
+
link.setAttribute("target", "_blank");
|
|
741
|
+
link.setAttribute("rel", "noopener noreferrer");
|
|
742
|
+
linksContainer.appendChild(link);
|
|
743
|
+
}
|
|
744
|
+
textContainer.appendChild(text);
|
|
745
|
+
const linkButtons = config.buttons.filter((b) => b.action === "link");
|
|
746
|
+
if (linkButtons.length > 0) {
|
|
747
|
+
for (const btn of linkButtons) {
|
|
748
|
+
if (!btn.url) continue;
|
|
749
|
+
const anchor = document.createElement("a");
|
|
750
|
+
anchor.href = btn.url;
|
|
751
|
+
anchor.className = `consenti-banner__link consenti-btn consenti-btn--${btn.style ?? "text"}`;
|
|
752
|
+
anchor.textContent = btn.text;
|
|
753
|
+
anchor.setAttribute("target", "_blank");
|
|
754
|
+
anchor.setAttribute("rel", "noopener noreferrer");
|
|
755
|
+
linksContainer.appendChild(anchor);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (linkButtons.length > 0 || privacyPolicyUrl) {
|
|
759
|
+
textContainer.appendChild(linksContainer);
|
|
760
|
+
}
|
|
761
|
+
banner.appendChild(textContainer);
|
|
762
|
+
const actionButtons = config.buttons.filter((b) => b.action !== "link");
|
|
763
|
+
if (actionButtons.length > 0) {
|
|
764
|
+
const btns = document.createElement("div");
|
|
765
|
+
btns.className = "consenti-banner__buttons";
|
|
766
|
+
for (const btn of actionButtons) {
|
|
767
|
+
btns.appendChild(buildButton(btn, handlers));
|
|
768
|
+
}
|
|
769
|
+
banner.appendChild(btns);
|
|
770
|
+
}
|
|
771
|
+
const hasClose = config.showClose;
|
|
772
|
+
const hasLocaleSwitcher = config.showLocaleSwitcher && locales && locales.length > 1;
|
|
773
|
+
if (hasClose || hasLocaleSwitcher) {
|
|
774
|
+
const controls = document.createElement("div");
|
|
775
|
+
controls.className = "consenti-banner__controls";
|
|
776
|
+
if (hasLocaleSwitcher && locales && onLocaleSwitch) {
|
|
777
|
+
controls.appendChild(buildLocaleSwitcher(locales, activeLocale, onLocaleSwitch));
|
|
778
|
+
}
|
|
779
|
+
if (hasClose) {
|
|
780
|
+
const close = document.createElement("button");
|
|
781
|
+
close.type = "button";
|
|
782
|
+
close.className = "consenti-banner__close consenti-btn consenti-btn--close";
|
|
783
|
+
close.setAttribute("aria-label", "Close cookie banner");
|
|
784
|
+
close.textContent = "\xD7";
|
|
785
|
+
close.addEventListener("click", handlers.onClose);
|
|
786
|
+
controls.appendChild(close);
|
|
787
|
+
}
|
|
788
|
+
banner.appendChild(controls);
|
|
789
|
+
}
|
|
790
|
+
if (config.overlayOpacity && config.overlayOpacity > 0) {
|
|
791
|
+
this.overlayEl = createOverlay(config.overlayOpacity);
|
|
792
|
+
}
|
|
793
|
+
this.el = banner;
|
|
794
|
+
return banner;
|
|
795
|
+
}
|
|
796
|
+
/** Returns the overlay element (if `overlayOpacity > 0`), or `null`. */
|
|
797
|
+
getOverlay() {
|
|
798
|
+
return this.overlayEl;
|
|
799
|
+
}
|
|
800
|
+
/** Makes the banner and its overlay visible after a `hide()` call. */
|
|
801
|
+
show() {
|
|
802
|
+
if (this.el) this.el.style.display = "";
|
|
803
|
+
if (this.overlayEl) this.overlayEl.style.display = "";
|
|
804
|
+
}
|
|
805
|
+
/** Hides the banner and its overlay without removing them from the DOM. */
|
|
806
|
+
hide() {
|
|
807
|
+
if (this.el) this.el.style.display = "none";
|
|
808
|
+
if (this.overlayEl) this.overlayEl.style.display = "none";
|
|
809
|
+
}
|
|
810
|
+
/** Removes the banner and overlay from the DOM entirely. */
|
|
811
|
+
remove() {
|
|
812
|
+
this.el?.remove();
|
|
813
|
+
this.overlayEl?.remove();
|
|
814
|
+
this.el = null;
|
|
815
|
+
this.overlayEl = null;
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
function buildLocaleSwitcher(locales, activeLocale, onSwitch) {
|
|
819
|
+
const wrapper = document.createElement("div");
|
|
820
|
+
wrapper.className = "consenti-locale-switcher";
|
|
821
|
+
const btn = document.createElement("button");
|
|
822
|
+
btn.type = "button";
|
|
823
|
+
btn.className = "consenti-locale-switcher__btn consenti-btn consenti-btn--close";
|
|
824
|
+
btn.setAttribute("aria-label", "Switch language");
|
|
825
|
+
btn.setAttribute("aria-haspopup", "listbox");
|
|
826
|
+
btn.textContent = "\u{1F310}";
|
|
827
|
+
const list = document.createElement("ul");
|
|
828
|
+
list.className = "consenti-locale-switcher__list";
|
|
829
|
+
list.setAttribute("role", "listbox");
|
|
830
|
+
list.setAttribute("aria-label", "Select language");
|
|
831
|
+
list.hidden = true;
|
|
832
|
+
for (const locale of locales) {
|
|
833
|
+
const item = document.createElement("li");
|
|
834
|
+
item.setAttribute("role", "option");
|
|
835
|
+
item.setAttribute("aria-selected", String(locale === activeLocale));
|
|
836
|
+
const localeBtn = document.createElement("button");
|
|
837
|
+
localeBtn.type = "button";
|
|
838
|
+
localeBtn.className = `consenti-locale-switcher__option${locale === activeLocale ? " consenti-locale-switcher__option--active" : ""}`;
|
|
839
|
+
localeBtn.textContent = locale;
|
|
840
|
+
localeBtn.addEventListener("click", (e) => {
|
|
841
|
+
e.stopPropagation();
|
|
842
|
+
list.hidden = true;
|
|
843
|
+
if (locale !== activeLocale) onSwitch(locale);
|
|
844
|
+
});
|
|
845
|
+
item.appendChild(localeBtn);
|
|
846
|
+
list.appendChild(item);
|
|
847
|
+
}
|
|
848
|
+
btn.addEventListener("click", (e) => {
|
|
849
|
+
e.stopPropagation();
|
|
850
|
+
list.hidden = !list.hidden;
|
|
851
|
+
});
|
|
852
|
+
document.addEventListener("click", () => {
|
|
853
|
+
list.hidden = true;
|
|
854
|
+
}, { capture: true, once: false });
|
|
855
|
+
wrapper.appendChild(btn);
|
|
856
|
+
wrapper.appendChild(list);
|
|
857
|
+
return wrapper;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/ui/focus-trap.ts
|
|
861
|
+
var FOCUSABLE_SELECTORS = [
|
|
862
|
+
"a[href]",
|
|
863
|
+
"button:not([disabled])",
|
|
864
|
+
"input:not([disabled])",
|
|
865
|
+
"select:not([disabled])",
|
|
866
|
+
"textarea:not([disabled])",
|
|
867
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
868
|
+
'[contenteditable="true"]'
|
|
869
|
+
].join(", ");
|
|
870
|
+
function trapFocus(container) {
|
|
871
|
+
const getFocusable = () => Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter(
|
|
872
|
+
(el) => !el.closest('[aria-hidden="true"]')
|
|
873
|
+
);
|
|
874
|
+
const handleKeydown = (e) => {
|
|
875
|
+
if (e.key !== "Tab") return;
|
|
876
|
+
const els = getFocusable();
|
|
877
|
+
if (els.length === 0) return;
|
|
878
|
+
const first = els[0];
|
|
879
|
+
const last = els[els.length - 1];
|
|
880
|
+
if (e.shiftKey) {
|
|
881
|
+
if (document.activeElement === first || document.activeElement === container) {
|
|
882
|
+
e.preventDefault();
|
|
883
|
+
last.focus();
|
|
884
|
+
}
|
|
885
|
+
} else {
|
|
886
|
+
if (document.activeElement === last) {
|
|
887
|
+
e.preventDefault();
|
|
888
|
+
first.focus();
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
const handleEscape = (e) => {
|
|
893
|
+
if (e.key === "Escape") {
|
|
894
|
+
container.dispatchEvent(new CustomEvent("consenti:closeRequest", { bubbles: true }));
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
document.addEventListener("keydown", handleKeydown);
|
|
898
|
+
document.addEventListener("keydown", handleEscape);
|
|
899
|
+
getFocusable()[0]?.focus();
|
|
900
|
+
return () => {
|
|
901
|
+
document.removeEventListener("keydown", handleKeydown);
|
|
902
|
+
document.removeEventListener("keydown", handleEscape);
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// src/ui/modal.ts
|
|
907
|
+
var Modal = class {
|
|
908
|
+
constructor() {
|
|
909
|
+
this.el = null;
|
|
910
|
+
this.triggerEl = null;
|
|
911
|
+
this.trapCleanup = null;
|
|
912
|
+
/** Map of category ID → toggle button element, used to read state on submit. */
|
|
913
|
+
this.toggleMap = /* @__PURE__ */ new Map();
|
|
914
|
+
this.receiptCheckbox = null;
|
|
915
|
+
/** Live toggle state — updated on each click so `getToggleConsent()` reads the latest. */
|
|
916
|
+
this.toggleState = /* @__PURE__ */ new Map();
|
|
917
|
+
/** When `true`, the modal wrapper is passthrough (overlayOpacity === 0). */
|
|
918
|
+
this.passthrough = false;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Constructs the modal DOM from the given config.
|
|
922
|
+
* The modal is not inserted into the document until `open()` is called.
|
|
923
|
+
*
|
|
924
|
+
* @param config - Modal layout and content config from the resolved profile.
|
|
925
|
+
* @param initialConsent - Current stored consent, used to pre-set toggle states.
|
|
926
|
+
* @param handlers - Click handlers wired to each button.
|
|
927
|
+
* @param allowReceipt - When `true`, renders the consent receipt download checkbox.
|
|
928
|
+
* @param dpdpaConfig - DPDPA grievance notice config.
|
|
929
|
+
* @param locales - All available locale codes (for locale switcher).
|
|
930
|
+
* @param activeLocale - The currently active locale code.
|
|
931
|
+
* @param onLocaleSwitch - Callback invoked when the user selects a different locale.
|
|
932
|
+
*/
|
|
933
|
+
build(config, initialConsent, handlers, allowReceipt, dpdpaConfig, locales, activeLocale, onLocaleSwitch) {
|
|
934
|
+
const position = config.position ?? "center";
|
|
935
|
+
const wrapper = document.createElement("div");
|
|
936
|
+
wrapper.id = "consenti-modal";
|
|
937
|
+
wrapper.className = `consenti-modal consenti-modal--${position}`;
|
|
938
|
+
wrapper.setAttribute("role", "dialog");
|
|
939
|
+
wrapper.setAttribute("aria-modal", "true");
|
|
940
|
+
const overlayOpacity = config.overlayOpacity ?? 50;
|
|
941
|
+
this.passthrough = overlayOpacity === 0;
|
|
942
|
+
if (overlayOpacity > 0) {
|
|
943
|
+
const overlayClickHandler = config.persistent ? void 0 : () => handlers.onClose();
|
|
944
|
+
const overlay = createOverlay(overlayOpacity, overlayClickHandler);
|
|
945
|
+
overlay.className = "consenti-modal__overlay";
|
|
946
|
+
wrapper.appendChild(overlay);
|
|
947
|
+
} else {
|
|
948
|
+
wrapper.classList.add("consenti-modal--passthrough");
|
|
949
|
+
}
|
|
950
|
+
const container = document.createElement("div");
|
|
951
|
+
container.className = "consenti-modal__container";
|
|
952
|
+
const header = document.createElement("div");
|
|
953
|
+
header.className = "consenti-modal__header";
|
|
954
|
+
if (config.heading) {
|
|
955
|
+
const headingTag = config.headingTag ?? "h2";
|
|
956
|
+
const heading = document.createElement(headingTag);
|
|
957
|
+
heading.id = "consenti-modal-heading";
|
|
958
|
+
heading.className = "consenti-modal__heading";
|
|
959
|
+
heading.textContent = config.heading;
|
|
960
|
+
header.appendChild(heading);
|
|
961
|
+
wrapper.setAttribute("aria-labelledby", "consenti-modal-heading");
|
|
962
|
+
}
|
|
963
|
+
const hasClose = config.showClose !== false;
|
|
964
|
+
const hasLocaleSwitcher = config.showLocaleSwitcher && locales && locales.length > 1;
|
|
965
|
+
if (hasLocaleSwitcher || hasClose) {
|
|
966
|
+
const headerControls = document.createElement("div");
|
|
967
|
+
headerControls.className = "consenti-modal__header-controls";
|
|
968
|
+
if (hasLocaleSwitcher && locales && onLocaleSwitch) {
|
|
969
|
+
headerControls.appendChild(buildModalLocaleSwitcher(locales, activeLocale, onLocaleSwitch));
|
|
970
|
+
}
|
|
971
|
+
if (hasClose) {
|
|
972
|
+
const close = document.createElement("button");
|
|
973
|
+
close.type = "button";
|
|
974
|
+
close.className = "consenti-modal__close consenti-btn consenti-btn--close";
|
|
975
|
+
close.setAttribute("aria-label", "Close preference modal");
|
|
976
|
+
close.textContent = "\xD7";
|
|
977
|
+
close.addEventListener("click", handlers.onClose);
|
|
978
|
+
headerControls.appendChild(close);
|
|
979
|
+
}
|
|
980
|
+
header.appendChild(headerControls);
|
|
981
|
+
}
|
|
982
|
+
container.appendChild(header);
|
|
983
|
+
if (config.subheading) {
|
|
984
|
+
const sub = document.createElement("p");
|
|
985
|
+
sub.className = "consenti-modal__subheading";
|
|
986
|
+
sub.textContent = config.subheading;
|
|
987
|
+
container.appendChild(sub);
|
|
988
|
+
}
|
|
989
|
+
if (config.htmlText) {
|
|
990
|
+
const desc = document.createElement("div");
|
|
991
|
+
desc.className = "consenti-modal__text";
|
|
992
|
+
desc.innerHTML = config.htmlText;
|
|
993
|
+
container.appendChild(desc);
|
|
994
|
+
}
|
|
995
|
+
const linkButtons = config.buttons.filter((b) => b.action === "link");
|
|
996
|
+
if (linkButtons.length > 0) {
|
|
997
|
+
const linksContainer = document.createElement("div");
|
|
998
|
+
linksContainer.className = "consenti-modal__links";
|
|
999
|
+
for (const btn of linkButtons) {
|
|
1000
|
+
if (!btn.url) continue;
|
|
1001
|
+
const anchor = document.createElement("a");
|
|
1002
|
+
anchor.href = btn.url;
|
|
1003
|
+
anchor.className = `consenti-modal__link consenti-btn consenti-btn--${btn.style ?? "text"}`;
|
|
1004
|
+
anchor.textContent = btn.text;
|
|
1005
|
+
anchor.setAttribute("target", "_blank");
|
|
1006
|
+
anchor.setAttribute("rel", "noopener noreferrer");
|
|
1007
|
+
linksContainer.appendChild(anchor);
|
|
1008
|
+
}
|
|
1009
|
+
container.appendChild(linksContainer);
|
|
1010
|
+
}
|
|
1011
|
+
const body = document.createElement("div");
|
|
1012
|
+
body.className = "consenti-modal__body";
|
|
1013
|
+
const categories = document.createElement("div");
|
|
1014
|
+
categories.className = "consenti-modal__categories";
|
|
1015
|
+
for (const cat of config.categories) {
|
|
1016
|
+
const catEl = this.buildCategory(cat, initialConsent);
|
|
1017
|
+
categories.appendChild(catEl);
|
|
1018
|
+
}
|
|
1019
|
+
body.appendChild(categories);
|
|
1020
|
+
container.appendChild(body);
|
|
1021
|
+
if (allowReceipt) {
|
|
1022
|
+
container.appendChild(this.buildReceiptOption());
|
|
1023
|
+
}
|
|
1024
|
+
if (dpdpaConfig) {
|
|
1025
|
+
const notice = document.createElement("div");
|
|
1026
|
+
notice.className = "consenti-modal__dpdpa-notice";
|
|
1027
|
+
notice.innerHTML = [
|
|
1028
|
+
`<strong>Data Fiduciary:</strong> ${dpdpaConfig.dataFiduciary}.`,
|
|
1029
|
+
dpdpaConfig.purposeDescription ? ` <strong>Purpose:</strong> ${dpdpaConfig.purposeDescription}.` : "",
|
|
1030
|
+
` You may withdraw consent at any time or contact our Grievance Officer at`,
|
|
1031
|
+
` <a href="mailto:${dpdpaConfig.grievanceEmail}">${dpdpaConfig.grievanceEmail}</a>.`
|
|
1032
|
+
].join("");
|
|
1033
|
+
container.appendChild(notice);
|
|
1034
|
+
}
|
|
1035
|
+
const btns = document.createElement("div");
|
|
1036
|
+
btns.className = "consenti-modal__buttons";
|
|
1037
|
+
for (const btn of config.buttons.filter((b) => b.action !== "link")) {
|
|
1038
|
+
btns.appendChild(buildButton(btn, handlers));
|
|
1039
|
+
}
|
|
1040
|
+
container.appendChild(btns);
|
|
1041
|
+
wrapper.appendChild(container);
|
|
1042
|
+
wrapper.addEventListener("consenti:closeRequest", () => handlers.onClose());
|
|
1043
|
+
this.el = wrapper;
|
|
1044
|
+
}
|
|
1045
|
+
buildCategory(cat, initialConsent) {
|
|
1046
|
+
const isLI = cat.type === "legitimate_interest";
|
|
1047
|
+
const isMandatory = cat.mandatory === true;
|
|
1048
|
+
let defaultOn;
|
|
1049
|
+
if (isMandatory) {
|
|
1050
|
+
defaultOn = true;
|
|
1051
|
+
} else if (isLI) {
|
|
1052
|
+
defaultOn = initialConsent[cat.cookies[0] ?? ""] !== "objected";
|
|
1053
|
+
} else {
|
|
1054
|
+
defaultOn = initialConsent[cat.cookies[0] ?? ""] === "granted";
|
|
1055
|
+
}
|
|
1056
|
+
this.toggleState.set(cat.id, defaultOn);
|
|
1057
|
+
const wrapper = document.createElement("div");
|
|
1058
|
+
wrapper.className = `consenti-category${isMandatory ? " consenti-category--mandatory" : ""}`;
|
|
1059
|
+
const header = document.createElement("div");
|
|
1060
|
+
header.className = "consenti-category__header";
|
|
1061
|
+
const headingTag = cat.headingTag ?? "h3";
|
|
1062
|
+
const heading = document.createElement(headingTag);
|
|
1063
|
+
heading.id = `consenti-cat-${cat.id}-heading`;
|
|
1064
|
+
heading.className = "consenti-category__heading";
|
|
1065
|
+
heading.textContent = cat.heading;
|
|
1066
|
+
header.appendChild(heading);
|
|
1067
|
+
const toggle = document.createElement("button");
|
|
1068
|
+
toggle.type = "button";
|
|
1069
|
+
toggle.className = `consenti-category__toggle${isMandatory ? " consenti-category__toggle--mandatory" : ""}`;
|
|
1070
|
+
toggle.setAttribute("role", "switch");
|
|
1071
|
+
toggle.setAttribute("aria-checked", String(defaultOn));
|
|
1072
|
+
toggle.setAttribute("aria-labelledby", `consenti-cat-${cat.id}-heading`);
|
|
1073
|
+
if (isMandatory) toggle.setAttribute("aria-disabled", "true");
|
|
1074
|
+
const knob = document.createElement("span");
|
|
1075
|
+
knob.className = "consenti-category__toggle-knob";
|
|
1076
|
+
knob.setAttribute("aria-hidden", "true");
|
|
1077
|
+
toggle.appendChild(knob);
|
|
1078
|
+
if (!isMandatory) {
|
|
1079
|
+
toggle.addEventListener("click", () => {
|
|
1080
|
+
const current = this.toggleState.get(cat.id) ?? false;
|
|
1081
|
+
const next = !current;
|
|
1082
|
+
this.toggleState.set(cat.id, next);
|
|
1083
|
+
toggle.setAttribute("aria-checked", String(next));
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
this.toggleMap.set(cat.id, toggle);
|
|
1087
|
+
header.appendChild(toggle);
|
|
1088
|
+
wrapper.appendChild(header);
|
|
1089
|
+
const text = document.createElement("div");
|
|
1090
|
+
text.className = "consenti-category__text";
|
|
1091
|
+
text.innerHTML = cat.htmlText;
|
|
1092
|
+
wrapper.appendChild(text);
|
|
1093
|
+
if (isLI && cat.legitimateInterest?.description) {
|
|
1094
|
+
const li = document.createElement("p");
|
|
1095
|
+
li.className = "consenti-category__li-desc";
|
|
1096
|
+
li.textContent = cat.legitimateInterest.description;
|
|
1097
|
+
wrapper.appendChild(li);
|
|
1098
|
+
}
|
|
1099
|
+
return wrapper;
|
|
1100
|
+
}
|
|
1101
|
+
buildReceiptOption() {
|
|
1102
|
+
const wrapper = document.createElement("div");
|
|
1103
|
+
wrapper.className = "consenti-modal__receipt-option";
|
|
1104
|
+
const label = document.createElement("label");
|
|
1105
|
+
label.className = "consenti-modal__receipt-label";
|
|
1106
|
+
const checkbox = document.createElement("input");
|
|
1107
|
+
checkbox.type = "checkbox";
|
|
1108
|
+
checkbox.className = "consenti-modal__receipt-checkbox";
|
|
1109
|
+
checkbox.id = "consenti-receipt-checkbox";
|
|
1110
|
+
checkbox.setAttribute("aria-describedby", "consenti-receipt-desc");
|
|
1111
|
+
this.receiptCheckbox = checkbox;
|
|
1112
|
+
label.appendChild(checkbox);
|
|
1113
|
+
label.append(" Get a copy of my consent choices (JSON)");
|
|
1114
|
+
wrapper.appendChild(label);
|
|
1115
|
+
const desc = document.createElement("p");
|
|
1116
|
+
desc.id = "consenti-receipt-desc";
|
|
1117
|
+
desc.className = "consenti-modal__receipt-desc";
|
|
1118
|
+
desc.textContent = "A JSON file will be downloaded to your device when you save your preferences.";
|
|
1119
|
+
wrapper.appendChild(desc);
|
|
1120
|
+
return wrapper;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Appends the modal to `document.body` and activates the focus trap.
|
|
1124
|
+
* The receipt checkbox (if present) is reset to unchecked.
|
|
1125
|
+
*
|
|
1126
|
+
* @param triggerEl - The element that opened the modal. Focus returns here on `close()`.
|
|
1127
|
+
*/
|
|
1128
|
+
open(triggerEl) {
|
|
1129
|
+
if (!this.el) return;
|
|
1130
|
+
this.triggerEl = triggerEl ?? document.activeElement;
|
|
1131
|
+
if (this.receiptCheckbox) this.receiptCheckbox.checked = false;
|
|
1132
|
+
document.body.appendChild(this.el);
|
|
1133
|
+
if (!this.passthrough) {
|
|
1134
|
+
this.trapCleanup = trapFocus(this.el);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Removes the modal from `document.body`, deactivates the focus trap,
|
|
1139
|
+
* and returns focus to `triggerEl`.
|
|
1140
|
+
*/
|
|
1141
|
+
close() {
|
|
1142
|
+
this.trapCleanup?.();
|
|
1143
|
+
this.trapCleanup = null;
|
|
1144
|
+
this.el?.remove();
|
|
1145
|
+
this.triggerEl?.focus();
|
|
1146
|
+
this.triggerEl = null;
|
|
1147
|
+
}
|
|
1148
|
+
/** Returns `true` if the modal is currently mounted in the document. */
|
|
1149
|
+
isOpen() {
|
|
1150
|
+
return this.el?.isConnected === true;
|
|
1151
|
+
}
|
|
1152
|
+
/** Returns `true` if the receipt download checkbox is checked at the time of calling. */
|
|
1153
|
+
isReceiptRequested() {
|
|
1154
|
+
return this.receiptCheckbox?.checked ?? false;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Reads the current toggle state map and converts it to a `ConsentValue`.
|
|
1158
|
+
*
|
|
1159
|
+
* Called by the submit handler to build the consent object before writing to storage.
|
|
1160
|
+
*
|
|
1161
|
+
* @param categories - The category list from the resolved profile (provides cookie IDs and type).
|
|
1162
|
+
*/
|
|
1163
|
+
getToggleConsent(categories) {
|
|
1164
|
+
const consent = {};
|
|
1165
|
+
for (const cat of categories) {
|
|
1166
|
+
const isLI = cat.type === "legitimate_interest";
|
|
1167
|
+
const isMandatory = cat.mandatory === true;
|
|
1168
|
+
const on = this.toggleState.get(cat.id) ?? false;
|
|
1169
|
+
for (const cookieId of cat.cookies) {
|
|
1170
|
+
if (isMandatory) {
|
|
1171
|
+
consent[cookieId] = "granted";
|
|
1172
|
+
} else if (isLI) {
|
|
1173
|
+
consent[cookieId] = on ? "granted" : "objected";
|
|
1174
|
+
} else {
|
|
1175
|
+
consent[cookieId] = on ? "granted" : "denied";
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
return consent;
|
|
1180
|
+
}
|
|
1181
|
+
/** Removes the modal element from the DOM and clears all internal state. */
|
|
1182
|
+
destroy() {
|
|
1183
|
+
this.trapCleanup?.();
|
|
1184
|
+
this.el?.remove();
|
|
1185
|
+
this.el = null;
|
|
1186
|
+
this.toggleMap.clear();
|
|
1187
|
+
this.toggleState.clear();
|
|
1188
|
+
this.receiptCheckbox = null;
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
function buildModalLocaleSwitcher(locales, activeLocale, onSwitch) {
|
|
1192
|
+
const wrapper = document.createElement("div");
|
|
1193
|
+
wrapper.className = "consenti-locale-switcher consenti-locale-switcher--modal";
|
|
1194
|
+
const btn = document.createElement("button");
|
|
1195
|
+
btn.type = "button";
|
|
1196
|
+
btn.className = "consenti-locale-switcher__btn consenti-btn consenti-btn--close";
|
|
1197
|
+
btn.setAttribute("aria-label", "Switch language");
|
|
1198
|
+
btn.setAttribute("aria-haspopup", "listbox");
|
|
1199
|
+
btn.textContent = "\u{1F310}";
|
|
1200
|
+
const list = document.createElement("ul");
|
|
1201
|
+
list.className = "consenti-locale-switcher__list";
|
|
1202
|
+
list.setAttribute("role", "listbox");
|
|
1203
|
+
list.setAttribute("aria-label", "Select language");
|
|
1204
|
+
list.hidden = true;
|
|
1205
|
+
for (const locale of locales) {
|
|
1206
|
+
const item = document.createElement("li");
|
|
1207
|
+
item.setAttribute("role", "option");
|
|
1208
|
+
item.setAttribute("aria-selected", String(locale === activeLocale));
|
|
1209
|
+
const localeBtn = document.createElement("button");
|
|
1210
|
+
localeBtn.type = "button";
|
|
1211
|
+
localeBtn.className = `consenti-locale-switcher__option${locale === activeLocale ? " consenti-locale-switcher__option--active" : ""}`;
|
|
1212
|
+
localeBtn.textContent = locale;
|
|
1213
|
+
localeBtn.addEventListener("click", (e) => {
|
|
1214
|
+
e.stopPropagation();
|
|
1215
|
+
list.hidden = true;
|
|
1216
|
+
if (locale !== activeLocale) onSwitch(locale);
|
|
1217
|
+
});
|
|
1218
|
+
item.appendChild(localeBtn);
|
|
1219
|
+
list.appendChild(item);
|
|
1220
|
+
}
|
|
1221
|
+
btn.addEventListener("click", (e) => {
|
|
1222
|
+
e.stopPropagation();
|
|
1223
|
+
list.hidden = !list.hidden;
|
|
1224
|
+
});
|
|
1225
|
+
document.addEventListener("click", () => {
|
|
1226
|
+
list.hidden = true;
|
|
1227
|
+
}, { capture: true, once: false });
|
|
1228
|
+
wrapper.appendChild(btn);
|
|
1229
|
+
wrapper.appendChild(list);
|
|
1230
|
+
return wrapper;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// src/ui/renderer.ts
|
|
1234
|
+
var ROOT_ID = "consenti-root";
|
|
1235
|
+
var Renderer = class {
|
|
1236
|
+
constructor() {
|
|
1237
|
+
this.root = null;
|
|
1238
|
+
this.styleEl = null;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Returns the mount-point element, creating `#consenti-root` if needed.
|
|
1242
|
+
*
|
|
1243
|
+
* When `rootEl` is supplied:
|
|
1244
|
+
* - `string` → resolved via `document.querySelector`. Throws if no match.
|
|
1245
|
+
* - `HTMLElement` → used directly. Throws if not attached to the document.
|
|
1246
|
+
* On subsequent calls the previously stored root is returned immediately.
|
|
1247
|
+
*
|
|
1248
|
+
* @throws `Error` if called in SSR or if a supplied `rootEl` cannot be resolved.
|
|
1249
|
+
*/
|
|
1250
|
+
mount(rootEl) {
|
|
1251
|
+
if (!isClient()) throw new Error("[Consenti] Renderer.mount() called in SSR context");
|
|
1252
|
+
if (this.root) return this.root;
|
|
1253
|
+
if (rootEl !== void 0) {
|
|
1254
|
+
if (typeof rootEl === "string") {
|
|
1255
|
+
const found = document.querySelector(rootEl);
|
|
1256
|
+
if (!found) throw new Error(`[Consenti] rootEl selector "${rootEl}" did not match any element.`);
|
|
1257
|
+
this.root = found;
|
|
1258
|
+
} else {
|
|
1259
|
+
if (!document.contains(rootEl)) throw new Error("[Consenti] rootEl element is not attached to the document.");
|
|
1260
|
+
this.root = rootEl;
|
|
1261
|
+
}
|
|
1262
|
+
return this.root;
|
|
1263
|
+
}
|
|
1264
|
+
let root = document.getElementById(ROOT_ID);
|
|
1265
|
+
if (!root) {
|
|
1266
|
+
root = document.createElement("div");
|
|
1267
|
+
root.id = ROOT_ID;
|
|
1268
|
+
document.body.appendChild(root);
|
|
1269
|
+
}
|
|
1270
|
+
this.root = root;
|
|
1271
|
+
return root;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Injects a `<style id="consenti-styles">` tag into `<head>` with the given CSS string.
|
|
1275
|
+
* No-ops if the style element already exists (idempotent).
|
|
1276
|
+
*
|
|
1277
|
+
* @param css - The CSS content to inject.
|
|
1278
|
+
*/
|
|
1279
|
+
injectStyles(css) {
|
|
1280
|
+
if (!isClient()) return;
|
|
1281
|
+
if (this.styleEl) return;
|
|
1282
|
+
const style = document.createElement("style");
|
|
1283
|
+
style.id = "consenti-styles";
|
|
1284
|
+
style.textContent = css;
|
|
1285
|
+
document.head.appendChild(style);
|
|
1286
|
+
this.styleEl = style;
|
|
1287
|
+
}
|
|
1288
|
+
/** Removes `#consenti-root` and the injected `<style>` tag from the document. */
|
|
1289
|
+
unmount() {
|
|
1290
|
+
this.root?.remove();
|
|
1291
|
+
this.styleEl?.remove();
|
|
1292
|
+
this.root = null;
|
|
1293
|
+
this.styleEl = null;
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Announces a message to screen readers via a visually-hidden `aria-live="polite"` region.
|
|
1297
|
+
*
|
|
1298
|
+
* The region is created on first call and reused. The message is cleared then
|
|
1299
|
+
* re-set via `requestAnimationFrame` to guarantee the change is detected by
|
|
1300
|
+
* AT even if the same string is announced twice in a row.
|
|
1301
|
+
*
|
|
1302
|
+
* @param message - The message to announce (plain text, not HTML).
|
|
1303
|
+
*/
|
|
1304
|
+
announceToScreenReader(message) {
|
|
1305
|
+
if (!isClient()) return;
|
|
1306
|
+
let announcer = document.getElementById("consenti-announcer");
|
|
1307
|
+
if (!announcer) {
|
|
1308
|
+
announcer = document.createElement("div");
|
|
1309
|
+
announcer.id = "consenti-announcer";
|
|
1310
|
+
announcer.setAttribute("role", "status");
|
|
1311
|
+
announcer.setAttribute("aria-live", "polite");
|
|
1312
|
+
announcer.setAttribute("aria-atomic", "true");
|
|
1313
|
+
announcer.style.cssText = "position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;";
|
|
1314
|
+
document.body.appendChild(announcer);
|
|
1315
|
+
}
|
|
1316
|
+
announcer.textContent = "";
|
|
1317
|
+
requestAnimationFrame(() => {
|
|
1318
|
+
if (announcer) announcer.textContent = message;
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
// src/core/consenti-setup.ts
|
|
1324
|
+
var ConsentiSetup = class {
|
|
1325
|
+
/**
|
|
1326
|
+
* Creates and initialises the consent widget.
|
|
1327
|
+
*
|
|
1328
|
+
* Construction is synchronous; the async `init()` is launched in the background.
|
|
1329
|
+
* Use `await widget.ready` or `widget.onReady(cb)` to run code after init completes.
|
|
1330
|
+
*
|
|
1331
|
+
* @param config - Widget configuration. See `ConsentiConfig` for all options.
|
|
1332
|
+
*/
|
|
1333
|
+
constructor(config) {
|
|
1334
|
+
this.config = config;
|
|
1335
|
+
this.initialized = false;
|
|
1336
|
+
this.initializing = false;
|
|
1337
|
+
this.profile = null;
|
|
1338
|
+
this.consentStore = null;
|
|
1339
|
+
this.eventBus = null;
|
|
1340
|
+
this.banner = null;
|
|
1341
|
+
this.modal = null;
|
|
1342
|
+
this.renderer = null;
|
|
1343
|
+
this.broadcastChannel = new ConsentiChannel();
|
|
1344
|
+
this.plugins = [];
|
|
1345
|
+
/** Tracks which banner variant is currently visible, or null when hidden. */
|
|
1346
|
+
this._bannerVariant = null;
|
|
1347
|
+
this.readyPromise = new Promise((resolve) => {
|
|
1348
|
+
this.readyResolve = resolve;
|
|
1349
|
+
});
|
|
1350
|
+
if (!isClient()) return;
|
|
1351
|
+
if (config.api?.enabled && config.core.storage === "localStorage") {
|
|
1352
|
+
console.warn(
|
|
1353
|
+
"[Consenti] localStorage storage mode cannot be used with API mode (API sets cookies via Set-Cookie header). Falling back to cookie storage."
|
|
1354
|
+
);
|
|
1355
|
+
config.core.storage = "cookie";
|
|
1356
|
+
}
|
|
1357
|
+
if (config.autoInit !== false) {
|
|
1358
|
+
this.init().catch((err) => console.warn("[Consenti] Init error:", err));
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
profileId() {
|
|
1362
|
+
return this.config.core.profileId ?? 0;
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Starts widget initialisation. Called automatically unless `autoInit: false` is set.
|
|
1366
|
+
* Can be called explicitly after construction when `autoInit: false`, or after `destroy()`
|
|
1367
|
+
* to re-initialise the same instance.
|
|
1368
|
+
*/
|
|
1369
|
+
async init() {
|
|
1370
|
+
if (!isClient()) return;
|
|
1371
|
+
if (this.initializing) return;
|
|
1372
|
+
this.initializing = true;
|
|
1373
|
+
this.initialized = false;
|
|
1374
|
+
this.readyPromise = new Promise((resolve) => {
|
|
1375
|
+
this.readyResolve = resolve;
|
|
1376
|
+
});
|
|
1377
|
+
try {
|
|
1378
|
+
const storageMode = this.config.core.storage ?? "cookie";
|
|
1379
|
+
const userId = this.config.core.userId ?? ConsentStore.getOrCreateUserId(this.profileId(), storageMode);
|
|
1380
|
+
this.profile = await resolveProfile(this.config);
|
|
1381
|
+
if (this.config.profileOverride) {
|
|
1382
|
+
this.profile = { ...this.profile, ...this.config.profileOverride };
|
|
1383
|
+
}
|
|
1384
|
+
this.eventBus = new EventBus(this.profileId(), this.config.utils?.gtm);
|
|
1385
|
+
this.consentStore = new ConsentStore(
|
|
1386
|
+
userId,
|
|
1387
|
+
this.profileId(),
|
|
1388
|
+
storageMode,
|
|
1389
|
+
this.config.core.cookieDomains
|
|
1390
|
+
);
|
|
1391
|
+
this.renderer = new Renderer();
|
|
1392
|
+
if (!this.config.core.disableCssTemplate && CONSENTI_CSS) {
|
|
1393
|
+
this.renderer.injectStyles(CONSENTI_CSS);
|
|
1394
|
+
}
|
|
1395
|
+
this.banner = new Banner();
|
|
1396
|
+
this.modal = new Modal();
|
|
1397
|
+
this.applyTheme();
|
|
1398
|
+
this.applyDarkMode();
|
|
1399
|
+
this.broadcastChannel.connect((msg) => this.handleBroadcastMessage(msg));
|
|
1400
|
+
await this.runPlugins();
|
|
1401
|
+
const signingKey = this.profile.cookieSigningKey ?? this.config.core.cookieSigningKey;
|
|
1402
|
+
const existing = this.config.core.signCookies && signingKey ? await this.consentStore.readAndVerify(signingKey) : this.consentStore.read();
|
|
1403
|
+
const hasExisting = existing !== null;
|
|
1404
|
+
this.eventBus.dispatch("consenti:bannerInitialized", {
|
|
1405
|
+
profileId: this.profileId(),
|
|
1406
|
+
hasExistingConsent: hasExisting
|
|
1407
|
+
});
|
|
1408
|
+
const gpcMode = this.config.core.autoHonorGPC ?? false;
|
|
1409
|
+
if (gpcMode && detectGPC()) {
|
|
1410
|
+
const gpcConsent = applyGPCToConsent(this.profile.cookies, {}, gpcMode);
|
|
1411
|
+
if (gpcMode === "strict") {
|
|
1412
|
+
await this.submitConsent(gpcConsent, true);
|
|
1413
|
+
this.initialized = true;
|
|
1414
|
+
this.readyResolve();
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (!hasExisting) {
|
|
1418
|
+
const gpcSigningKey = this.profile.cookieSigningKey ?? this.config.core.cookieSigningKey;
|
|
1419
|
+
if (this.config.core.signCookies && gpcSigningKey && this.consentStore) {
|
|
1420
|
+
this.lastSignature = await this.consentStore.writeAndSign(gpcConsent, gpcSigningKey);
|
|
1421
|
+
} else {
|
|
1422
|
+
this.consentStore?.write(gpcConsent);
|
|
1423
|
+
}
|
|
1424
|
+
this.broadcastChannel.send({
|
|
1425
|
+
type: "consentUpdated",
|
|
1426
|
+
consent: gpcConsent,
|
|
1427
|
+
profileId: this.profileId()
|
|
1428
|
+
});
|
|
1429
|
+
this.eventBus?.dispatch("consenti:consentSubmitted", {
|
|
1430
|
+
consent: gpcConsent,
|
|
1431
|
+
gpcDetected: true,
|
|
1432
|
+
consentId: crypto.randomUUID(),
|
|
1433
|
+
consentAction: this.deriveAction(gpcConsent, false),
|
|
1434
|
+
pageUrl: window.location.href
|
|
1435
|
+
});
|
|
1436
|
+
this.showBanner(true);
|
|
1437
|
+
this.initialized = true;
|
|
1438
|
+
this.readyResolve();
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
const regulation = this.config.core.regulation ?? "gdpr";
|
|
1443
|
+
if (regulation === "ccpa" && !hasExisting) {
|
|
1444
|
+
const defaultConsent = {};
|
|
1445
|
+
for (const cookie of this.profile.cookies) {
|
|
1446
|
+
defaultConsent[cookie.id] = "granted";
|
|
1447
|
+
}
|
|
1448
|
+
const ccpaSigKey = this.profile.cookieSigningKey ?? this.config.core.cookieSigningKey;
|
|
1449
|
+
if (this.config.core.signCookies && ccpaSigKey) {
|
|
1450
|
+
await this.consentStore.writeAndSign(defaultConsent, ccpaSigKey);
|
|
1451
|
+
} else {
|
|
1452
|
+
this.consentStore.write(defaultConsent);
|
|
1453
|
+
}
|
|
1454
|
+
this.initialized = true;
|
|
1455
|
+
this.readyResolve();
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
if (regulation === "cpra" && !hasExisting) {
|
|
1459
|
+
const gpcActive = this.config.core.autoHonorGPC && detectGPC();
|
|
1460
|
+
const defaultConsent = {};
|
|
1461
|
+
for (const cookie of this.profile.cookies) {
|
|
1462
|
+
if (cookie.mandatory) {
|
|
1463
|
+
defaultConsent[cookie.id] = "granted";
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (cookie.cpraCategory === "sensitive") {
|
|
1467
|
+
defaultConsent[cookie.id] = "denied";
|
|
1468
|
+
} else if (gpcActive && (cookie.cpraCategory === "sale" || cookie.cpraCategory === "sharing")) {
|
|
1469
|
+
defaultConsent[cookie.id] = "denied";
|
|
1470
|
+
} else {
|
|
1471
|
+
defaultConsent[cookie.id] = "granted";
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
const sigKey = this.profile.cookieSigningKey ?? this.config.core.cookieSigningKey;
|
|
1475
|
+
if (this.config.core.signCookies && sigKey) {
|
|
1476
|
+
await this.consentStore.writeAndSign(defaultConsent, sigKey);
|
|
1477
|
+
} else {
|
|
1478
|
+
this.consentStore.write(defaultConsent);
|
|
1479
|
+
}
|
|
1480
|
+
this.initialized = true;
|
|
1481
|
+
this.readyResolve();
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (!hasExisting) {
|
|
1485
|
+
this.showBanner();
|
|
1486
|
+
}
|
|
1487
|
+
this.initialized = true;
|
|
1488
|
+
this.readyResolve();
|
|
1489
|
+
} finally {
|
|
1490
|
+
this.initializing = false;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/** Applies the `consenti-root--dark` class based on config or profile flag. */
|
|
1494
|
+
applyDarkMode() {
|
|
1495
|
+
if (!this.renderer) return;
|
|
1496
|
+
const isDark = this.config.darkMode ?? this.profile?.darkMode ?? false;
|
|
1497
|
+
const root = this.renderer.mount(this.config.rootEl);
|
|
1498
|
+
if (isDark) {
|
|
1499
|
+
root.classList.add("consenti-root--dark");
|
|
1500
|
+
} else {
|
|
1501
|
+
root.classList.remove("consenti-root--dark");
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Injects CSS custom property overrides supplied via `core.theme` into the
|
|
1506
|
+
* Consenti root element so they cascade down to all widget elements.
|
|
1507
|
+
*/
|
|
1508
|
+
applyTheme() {
|
|
1509
|
+
const theme = this.config.core.theme;
|
|
1510
|
+
if (!theme || !this.renderer) return;
|
|
1511
|
+
const map = {
|
|
1512
|
+
"--consenti-color-bg": theme.bgColor,
|
|
1513
|
+
"--consenti-color-text": theme.textColor,
|
|
1514
|
+
"--consenti-color-primary": theme.primaryColor,
|
|
1515
|
+
"--consenti-color-primary-text": theme.primaryTextColor,
|
|
1516
|
+
"--consenti-color-secondary": theme.secondaryColor,
|
|
1517
|
+
"--consenti-color-secondary-text": theme.secondaryTextColor,
|
|
1518
|
+
"--consenti-color-border": theme.borderColor,
|
|
1519
|
+
"--consenti-font-family": theme.fontFamily,
|
|
1520
|
+
"--consenti-font-size-base": theme.fontSizeBase,
|
|
1521
|
+
"--consenti-font-size-heading": theme.fontSizeHeading,
|
|
1522
|
+
"--consenti-border-radius": theme.borderRadius,
|
|
1523
|
+
"--consenti-border-radius-btn": theme.buttonBorderRadius,
|
|
1524
|
+
"--consenti-toggle-bg-on": theme.toggleBgOn,
|
|
1525
|
+
"--consenti-toggle-bg-off": theme.toggleBgOff,
|
|
1526
|
+
"--consenti-color-accent": theme.accentColor,
|
|
1527
|
+
"--consenti-color-accent-text": theme.accentTextColor
|
|
1528
|
+
};
|
|
1529
|
+
const root = this.renderer.mount(this.config.rootEl);
|
|
1530
|
+
for (const [prop, value] of Object.entries(map)) {
|
|
1531
|
+
if (value !== void 0) root.style.setProperty(prop, value);
|
|
1532
|
+
}
|
|
1533
|
+
if (theme.fontSizeMultiplier) {
|
|
1534
|
+
const mult = parseFloat(theme.fontSizeMultiplier);
|
|
1535
|
+
if (!isNaN(mult)) {
|
|
1536
|
+
const baseSize = parseFloat(getComputedStyle(root).getPropertyValue("--consenti-font-size-base") || "14");
|
|
1537
|
+
const headingSize = parseFloat(getComputedStyle(root).getPropertyValue("--consenti-font-size-heading") || "16");
|
|
1538
|
+
root.style.setProperty("--consenti-font-size-base", `${Math.round(baseSize * mult)}px`);
|
|
1539
|
+
root.style.setProperty("--consenti-font-size-heading", `${Math.round(headingSize * mult)}px`);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
async runPlugins() {
|
|
1544
|
+
this.plugins = this.config.plugins ?? [];
|
|
1545
|
+
for (const plugin of this.plugins) {
|
|
1546
|
+
try {
|
|
1547
|
+
await plugin.initialize(this);
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
console.warn("[Consenti] Plugin initialize error:", err);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Invokes an optional plugin hook on all registered plugins.
|
|
1555
|
+
* Async hooks are awaited; errors are caught and logged so a broken plugin
|
|
1556
|
+
* never interferes with the consent flow.
|
|
1557
|
+
*
|
|
1558
|
+
* @param invoke - Function that calls the desired hook method on a single plugin.
|
|
1559
|
+
*/
|
|
1560
|
+
async callPluginHook(invoke) {
|
|
1561
|
+
for (const plugin of this.plugins) {
|
|
1562
|
+
try {
|
|
1563
|
+
await invoke(plugin, this);
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
console.warn("[Consenti] Plugin hook failed:", err);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
buildHandlers(context) {
|
|
1570
|
+
return {
|
|
1571
|
+
onGrantAll: () => {
|
|
1572
|
+
const consent = {};
|
|
1573
|
+
for (const cookie of this.profile?.cookies ?? []) {
|
|
1574
|
+
consent[cookie.id] = "granted";
|
|
1575
|
+
}
|
|
1576
|
+
void this.submitConsent(consent);
|
|
1577
|
+
if (context === "banner") {
|
|
1578
|
+
this.banner?.hide();
|
|
1579
|
+
this._bannerVariant = null;
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
onDenyAll: () => {
|
|
1583
|
+
const consent = {};
|
|
1584
|
+
for (const cookie of this.profile?.cookies ?? []) {
|
|
1585
|
+
consent[cookie.id] = cookie.mandatory ? "granted" : "denied";
|
|
1586
|
+
}
|
|
1587
|
+
void this.submitConsent(consent);
|
|
1588
|
+
if (context === "banner") {
|
|
1589
|
+
this.banner?.hide();
|
|
1590
|
+
this._bannerVariant = null;
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
onSubmit: () => {
|
|
1594
|
+
if (context === "modal" && this.modal && this.profile) {
|
|
1595
|
+
const consent = this.modal.getToggleConsent(this.profile.preferenceModal.categories);
|
|
1596
|
+
void this.submitConsent(consent);
|
|
1597
|
+
this.hideModal();
|
|
1598
|
+
} else {
|
|
1599
|
+
const consent = {};
|
|
1600
|
+
for (const cookie of this.profile?.cookies ?? []) {
|
|
1601
|
+
if (cookie.mandatory) consent[cookie.id] = "granted";
|
|
1602
|
+
}
|
|
1603
|
+
void this.submitConsent(consent);
|
|
1604
|
+
if (context === "banner") {
|
|
1605
|
+
this.banner?.hide();
|
|
1606
|
+
this._bannerVariant = null;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
},
|
|
1610
|
+
onManage: () => {
|
|
1611
|
+
this.showModal();
|
|
1612
|
+
},
|
|
1613
|
+
onClose: () => {
|
|
1614
|
+
if (context === "modal") {
|
|
1615
|
+
this.hideModal();
|
|
1616
|
+
} else {
|
|
1617
|
+
this.banner?.hide();
|
|
1618
|
+
this._bannerVariant = null;
|
|
1619
|
+
this.broadcastChannel.send({ type: "bannerClosed" });
|
|
1620
|
+
this.eventBus?.dispatch("consenti:bannerVisibility", { show: false, action: true });
|
|
1621
|
+
void this.callPluginHook((p) => p.onBannerHide?.());
|
|
1622
|
+
}
|
|
1623
|
+
},
|
|
1624
|
+
onGrantSpecific: (cookieIds) => {
|
|
1625
|
+
const consent = {};
|
|
1626
|
+
for (const cookie of this.profile?.cookies ?? []) {
|
|
1627
|
+
if (cookie.mandatory || cookieIds.includes(cookie.id)) {
|
|
1628
|
+
consent[cookie.id] = "granted";
|
|
1629
|
+
} else {
|
|
1630
|
+
consent[cookie.id] = "denied";
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
void this.submitConsent(consent);
|
|
1634
|
+
if (context === "banner") this.hideBanner();
|
|
1635
|
+
}
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Classifies a consent submission as `accept_all`, `reject_all`, `custom`, or `update`.
|
|
1640
|
+
* Used to enrich events and API payloads with intent metadata.
|
|
1641
|
+
*/
|
|
1642
|
+
deriveAction(fullConsent, hadExistingConsent) {
|
|
1643
|
+
if (hadExistingConsent) return "update";
|
|
1644
|
+
const nonMandatory = this.profile?.cookies.filter((c) => !c.mandatory) ?? [];
|
|
1645
|
+
if (nonMandatory.length === 0) return "custom";
|
|
1646
|
+
const allGranted = nonMandatory.every((c) => fullConsent[c.id] === "granted");
|
|
1647
|
+
const allDenied = nonMandatory.every(
|
|
1648
|
+
(c) => fullConsent[c.id] === "denied" || fullConsent[c.id] === "objected"
|
|
1649
|
+
);
|
|
1650
|
+
if (allGranted) return "accept_all";
|
|
1651
|
+
if (allDenied) return "reject_all";
|
|
1652
|
+
return "custom";
|
|
1653
|
+
}
|
|
1654
|
+
handleBroadcastMessage(msg) {
|
|
1655
|
+
switch (msg.type) {
|
|
1656
|
+
case "consentUpdated":
|
|
1657
|
+
if (msg.profileId !== this.profileId()) return;
|
|
1658
|
+
this.consentStore?.setInMemory(msg.consent);
|
|
1659
|
+
this.banner?.hide();
|
|
1660
|
+
if (this.modal?.isOpen()) this.modal.close();
|
|
1661
|
+
this.eventBus?.dispatch("consenti:consentSubmitted", {
|
|
1662
|
+
consent: msg.consent,
|
|
1663
|
+
fromBroadcast: true
|
|
1664
|
+
});
|
|
1665
|
+
break;
|
|
1666
|
+
case "bannerClosed":
|
|
1667
|
+
this.banner?.hide();
|
|
1668
|
+
this._bannerVariant = null;
|
|
1669
|
+
break;
|
|
1670
|
+
case "consentDeleted":
|
|
1671
|
+
this.consentStore?.clearInMemory();
|
|
1672
|
+
break;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
1676
|
+
/**
|
|
1677
|
+
* Returns `true` if the visitor has saved consent for the active profile.
|
|
1678
|
+
* Always returns `false` during SSR.
|
|
1679
|
+
*/
|
|
1680
|
+
hasConsent() {
|
|
1681
|
+
if (!isClient()) return false;
|
|
1682
|
+
return this.consentStore?.hasConsent() ?? false;
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Returns the stored consent map, or `null` if no consent has been saved.
|
|
1686
|
+
* Always returns `null` during SSR.
|
|
1687
|
+
*/
|
|
1688
|
+
getConsent() {
|
|
1689
|
+
if (!isClient()) return null;
|
|
1690
|
+
return this.consentStore?.getConsent() ?? null;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Returns the `Date` when consent was last saved, or `false` if no consent exists.
|
|
1694
|
+
* Always returns `false` during SSR.
|
|
1695
|
+
*/
|
|
1696
|
+
getConsentDate() {
|
|
1697
|
+
if (!isClient()) return false;
|
|
1698
|
+
return this.consentStore?.getConsentDate() ?? false;
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Returns the consent map in Google Consent Mode v2 format, ready to pass to
|
|
1702
|
+
* `gtag('consent', 'update', ...)`. Returns `null` if no consent has been saved.
|
|
1703
|
+
*
|
|
1704
|
+
* Maps Consenti cookie IDs to the seven Google Consent Mode v2 parameters.
|
|
1705
|
+
* Cookies whose IDs exactly match a GCM v2 key are forwarded directly.
|
|
1706
|
+
* Non-GCM cookies are ignored by this method (they appear in `getConsent()` only).
|
|
1707
|
+
*/
|
|
1708
|
+
getGTMConsent() {
|
|
1709
|
+
const consent = this.getConsent();
|
|
1710
|
+
if (!consent) return null;
|
|
1711
|
+
const gcmKeys = [
|
|
1712
|
+
"ad_storage",
|
|
1713
|
+
"analytics_storage",
|
|
1714
|
+
"ad_user_data",
|
|
1715
|
+
"ad_personalization",
|
|
1716
|
+
"functionality_storage",
|
|
1717
|
+
"personalization_storage",
|
|
1718
|
+
"security_storage"
|
|
1719
|
+
];
|
|
1720
|
+
const result = {};
|
|
1721
|
+
for (const key of gcmKeys) {
|
|
1722
|
+
const raw = consent[key];
|
|
1723
|
+
if (raw === "granted") {
|
|
1724
|
+
result[key] = "granted";
|
|
1725
|
+
} else if (raw === "denied" || raw === "objected") {
|
|
1726
|
+
result[key] = "denied";
|
|
1727
|
+
} else {
|
|
1728
|
+
const safeDefault = key === "functionality_storage" || key === "security_storage" ? "granted" : "denied";
|
|
1729
|
+
result[key] = safeDefault;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return result;
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Records and persists the user's consent choices.
|
|
1736
|
+
*
|
|
1737
|
+
* Mandatory cookies are always forced to `'granted'` regardless of what is passed.
|
|
1738
|
+
* Non-mandatory cookies not present in `consent` default to `'denied'`.
|
|
1739
|
+
*
|
|
1740
|
+
* @param consent - Partial or full map of cookie ID → status.
|
|
1741
|
+
* @param gpcDerived - Internal flag set to `true` when called from the GPC strict path.
|
|
1742
|
+
*/
|
|
1743
|
+
async submitConsent(consent, gpcDerived = false) {
|
|
1744
|
+
if (!isClient() || !this.profile || !this.consentStore) return;
|
|
1745
|
+
const regulation = this.config.core.regulation ?? "gdpr";
|
|
1746
|
+
const hadExistingConsent = this.consentStore.hasConsent();
|
|
1747
|
+
const consentId = crypto.randomUUID();
|
|
1748
|
+
const pageUrl = window.location.href;
|
|
1749
|
+
const fullConsent = {};
|
|
1750
|
+
for (const cookie of this.profile.cookies) {
|
|
1751
|
+
if (cookie.mandatory) {
|
|
1752
|
+
fullConsent[cookie.id] = "granted";
|
|
1753
|
+
} else if (regulation === "cpra") {
|
|
1754
|
+
const fallback = cookie.cpraCategory === "sensitive" ? "denied" : "granted";
|
|
1755
|
+
fullConsent[cookie.id] = consent[cookie.id] ?? fallback;
|
|
1756
|
+
} else {
|
|
1757
|
+
fullConsent[cookie.id] = consent[cookie.id] ?? (regulation === "ccpa" ? "granted" : "denied");
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
const consentAction = this.deriveAction(fullConsent, hadExistingConsent);
|
|
1761
|
+
this.eventBus?.dispatch("consenti:consentBeingSubmitted", { consent: fullConsent, consentId });
|
|
1762
|
+
let apiResponse;
|
|
1763
|
+
if (this.config.api?.enabled) {
|
|
1764
|
+
try {
|
|
1765
|
+
const base = this.config.api.baseUrl ?? window.location.origin;
|
|
1766
|
+
const { httpRequest: httpRequest2 } = await import("./http-VHOGZ6ZJ.mjs");
|
|
1767
|
+
apiResponse = await httpRequest2(
|
|
1768
|
+
`${base}/consenti/api/v1/consent`,
|
|
1769
|
+
{
|
|
1770
|
+
method: "POST",
|
|
1771
|
+
body: JSON.stringify({
|
|
1772
|
+
profileId: this.profileId(),
|
|
1773
|
+
consent: fullConsent,
|
|
1774
|
+
locale: this.config.core.locale ?? "en",
|
|
1775
|
+
gpcDetected: gpcDerived || detectGPC(),
|
|
1776
|
+
consentId,
|
|
1777
|
+
action: consentAction,
|
|
1778
|
+
pageUrl
|
|
1779
|
+
})
|
|
1780
|
+
},
|
|
1781
|
+
this.config.api.authToken
|
|
1782
|
+
);
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
console.warn("[Consenti] API consent submission failed:", err);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
const signingKey = this.profile.cookieSigningKey ?? this.config.core.cookieSigningKey;
|
|
1788
|
+
if (this.config.core.signCookies && signingKey) {
|
|
1789
|
+
this.lastSignature = await this.consentStore.writeAndSign(fullConsent, signingKey);
|
|
1790
|
+
} else {
|
|
1791
|
+
this.consentStore.write(fullConsent);
|
|
1792
|
+
}
|
|
1793
|
+
const receiptRequested = this.modal?.isReceiptRequested() ?? false;
|
|
1794
|
+
if (receiptRequested && this.config.core.allowReceipt) {
|
|
1795
|
+
const nameParts = this.consentStore.cookieName.split("_");
|
|
1796
|
+
const visitorId = nameParts.slice(1, -1).join("_");
|
|
1797
|
+
const receiptParams = {
|
|
1798
|
+
visitorId,
|
|
1799
|
+
profileId: this.profileId(),
|
|
1800
|
+
profileVersion: this.profile.version,
|
|
1801
|
+
locale: this.config.core.locale ?? "en",
|
|
1802
|
+
consent: fullConsent,
|
|
1803
|
+
...this.lastSignature ? { signature: this.lastSignature } : {}
|
|
1804
|
+
};
|
|
1805
|
+
const receipt = generateReceipt(receiptParams);
|
|
1806
|
+
downloadReceipt(receipt);
|
|
1807
|
+
}
|
|
1808
|
+
this.broadcastChannel.send({
|
|
1809
|
+
type: "consentUpdated",
|
|
1810
|
+
consent: fullConsent,
|
|
1811
|
+
profileId: this.profileId()
|
|
1812
|
+
});
|
|
1813
|
+
this.eventBus?.dispatch("consenti:consentSubmitted", {
|
|
1814
|
+
consent: fullConsent,
|
|
1815
|
+
apiResponse,
|
|
1816
|
+
gpcDetected: gpcDerived,
|
|
1817
|
+
consentId,
|
|
1818
|
+
consentAction,
|
|
1819
|
+
pageUrl
|
|
1820
|
+
});
|
|
1821
|
+
await this.callPluginHook((p) => p.onConsentSubmit?.(fullConsent));
|
|
1822
|
+
this.renderer?.announceToScreenReader("Your cookie preferences have been saved.");
|
|
1823
|
+
this.hideBanner();
|
|
1824
|
+
if (this.modal?.isOpen()) this.hideModal();
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Returns the currently visible banner variant, or `false` if no banner is shown.
|
|
1828
|
+
* Useful for programmatically checking state before calling `showBanner` / `hideBanner`.
|
|
1829
|
+
*/
|
|
1830
|
+
bannerVisibility() {
|
|
1831
|
+
return this._bannerVariant ?? false;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Returns `'preference'` when the preference modal is open, or `false` when closed.
|
|
1835
|
+
*/
|
|
1836
|
+
modalVisibility() {
|
|
1837
|
+
return this.modal?.isOpen() ? "preference" : false;
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Mounts and shows the consent banner.
|
|
1841
|
+
*
|
|
1842
|
+
* No-ops if the requested variant is already visible. If a different variant is
|
|
1843
|
+
* currently shown (e.g. switching from main to GPC), the existing banner is replaced.
|
|
1844
|
+
*
|
|
1845
|
+
* @param useGpcVariant - When `true` and the profile has a `gpcBanner`, that variant
|
|
1846
|
+
* is shown instead of `mainBanner`. Used by the GPC flow.
|
|
1847
|
+
*/
|
|
1848
|
+
showBanner(useGpcVariant = false) {
|
|
1849
|
+
if (!isClient() || !this.profile || !this.banner || !this.renderer) return;
|
|
1850
|
+
const variant = useGpcVariant && this.profile.gpcBanner ? "gpc" : "main";
|
|
1851
|
+
if (this._bannerVariant === variant) return;
|
|
1852
|
+
const bannerConfig = variant === "gpc" ? this.profile.gpcBanner : this.profile.mainBanner;
|
|
1853
|
+
const root = this.renderer.mount(this.config.rootEl);
|
|
1854
|
+
const handlers = this.buildHandlers("banner");
|
|
1855
|
+
this.banner.remove();
|
|
1856
|
+
const locales = this.profile.locales;
|
|
1857
|
+
const activeLocale = this.config.core.locale ?? "en";
|
|
1858
|
+
const bannerEl = this.banner.build(
|
|
1859
|
+
bannerConfig,
|
|
1860
|
+
handlers,
|
|
1861
|
+
this.config.core.privacyPolicyUrl,
|
|
1862
|
+
locales,
|
|
1863
|
+
activeLocale,
|
|
1864
|
+
(locale) => this.switchLocale(locale)
|
|
1865
|
+
);
|
|
1866
|
+
root.appendChild(bannerEl);
|
|
1867
|
+
const overlay = this.banner.getOverlay();
|
|
1868
|
+
if (overlay) root.appendChild(overlay);
|
|
1869
|
+
this._bannerVariant = variant;
|
|
1870
|
+
this.eventBus?.dispatch("consenti:bannerVisibility", { show: true, action: false });
|
|
1871
|
+
void this.callPluginHook((p) => p.onBannerShow?.());
|
|
1872
|
+
}
|
|
1873
|
+
/** Hides the banner and notifies other tabs via BroadcastChannel. No-op if already hidden. */
|
|
1874
|
+
hideBanner() {
|
|
1875
|
+
if (this._bannerVariant === null) return;
|
|
1876
|
+
this.banner?.hide();
|
|
1877
|
+
this._bannerVariant = null;
|
|
1878
|
+
this.broadcastChannel.send({ type: "bannerClosed" });
|
|
1879
|
+
this.eventBus?.dispatch("consenti:bannerVisibility", { show: false, action: true });
|
|
1880
|
+
this.renderer?.announceToScreenReader("Cookie banner dismissed.");
|
|
1881
|
+
void this.callPluginHook((p) => p.onBannerHide?.());
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* Opens the preference modal and hides the banner while the modal is open.
|
|
1885
|
+
* No-ops if the modal is already open.
|
|
1886
|
+
* If the modal is closed without saving consent, the banner is re-shown automatically
|
|
1887
|
+
* (see `hideModal()`). Only one banner or modal is visible at a time.
|
|
1888
|
+
*
|
|
1889
|
+
* @param triggerEl - The element that triggered the modal open. Focus returns here on close.
|
|
1890
|
+
*/
|
|
1891
|
+
showModal(triggerEl) {
|
|
1892
|
+
if (!isClient() || !this.profile || !this.modal) return;
|
|
1893
|
+
if (this.modal.isOpen()) return;
|
|
1894
|
+
const handlers = this.buildHandlers("modal");
|
|
1895
|
+
const existing = this.consentStore?.getConsent() ?? {};
|
|
1896
|
+
const regulation = this.config.core.regulation ?? "gdpr";
|
|
1897
|
+
const dpdpaCfg = regulation === "dpdpa" ? this.profile.dpdpa : void 0;
|
|
1898
|
+
const locales = this.profile.locales;
|
|
1899
|
+
const activeLocale = this.config.core.locale ?? "en";
|
|
1900
|
+
this.modal.build(
|
|
1901
|
+
this.profile.preferenceModal,
|
|
1902
|
+
existing,
|
|
1903
|
+
handlers,
|
|
1904
|
+
this.config.core.allowReceipt ?? false,
|
|
1905
|
+
dpdpaCfg,
|
|
1906
|
+
locales,
|
|
1907
|
+
activeLocale,
|
|
1908
|
+
(locale) => {
|
|
1909
|
+
this.hideModal();
|
|
1910
|
+
this.switchLocale(locale);
|
|
1911
|
+
}
|
|
1912
|
+
);
|
|
1913
|
+
this.banner?.hide();
|
|
1914
|
+
this.modal.open(triggerEl);
|
|
1915
|
+
const isDark = this.config.darkMode ?? this.profile?.darkMode ?? false;
|
|
1916
|
+
document.getElementById("consenti-modal")?.classList.toggle("consenti-root--dark", isDark);
|
|
1917
|
+
this.eventBus?.dispatch("consenti:modalVisibility", { show: true, action: true });
|
|
1918
|
+
void this.callPluginHook((p) => p.onModalShow?.());
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Closes the preference modal and returns focus to the element that opened it.
|
|
1922
|
+
* No-ops if the modal is already closed.
|
|
1923
|
+
* If the user has not yet given consent, the banner is re-shown automatically.
|
|
1924
|
+
*/
|
|
1925
|
+
hideModal() {
|
|
1926
|
+
if (!this.modal?.isOpen()) return;
|
|
1927
|
+
this.modal.close();
|
|
1928
|
+
this.eventBus?.dispatch("consenti:modalVisibility", { show: false, action: true });
|
|
1929
|
+
void this.callPluginHook((p) => p.onModalHide?.());
|
|
1930
|
+
if (!this.consentStore?.hasConsent() && this._bannerVariant !== null) {
|
|
1931
|
+
this.banner?.show();
|
|
1932
|
+
} else if (!this.consentStore?.hasConsent()) {
|
|
1933
|
+
this.showBanner();
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Registers a callback to run once the widget has finished initialising.
|
|
1938
|
+
* If the widget is already initialised, the callback is invoked synchronously.
|
|
1939
|
+
*
|
|
1940
|
+
* @param callback - Function to call when the widget is ready.
|
|
1941
|
+
*/
|
|
1942
|
+
onReady(callback) {
|
|
1943
|
+
if (this.initialized) {
|
|
1944
|
+
callback();
|
|
1945
|
+
} else {
|
|
1946
|
+
void this.readyPromise.then(callback);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* A `Promise` that resolves once the widget has finished initialising.
|
|
1951
|
+
* Equivalent to wrapping `onReady()` in a `Promise`.
|
|
1952
|
+
*
|
|
1953
|
+
* @example
|
|
1954
|
+
* ```ts
|
|
1955
|
+
* await widget.ready
|
|
1956
|
+
* console.log('Widget ready')
|
|
1957
|
+
* ```
|
|
1958
|
+
*/
|
|
1959
|
+
get ready() {
|
|
1960
|
+
return this.readyPromise;
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Deletes the stored consent record and notifies other tabs.
|
|
1964
|
+
* The banner will reappear on the next page load (or immediately if the
|
|
1965
|
+
* receiving tab calls `showBanner()` in response to `consentDeleted`).
|
|
1966
|
+
*/
|
|
1967
|
+
async deleteConsent() {
|
|
1968
|
+
if (!isClient() || !this.consentStore) return;
|
|
1969
|
+
if (this.config.api?.enabled) {
|
|
1970
|
+
try {
|
|
1971
|
+
const base = this.config.api.baseUrl ?? window.location.origin;
|
|
1972
|
+
const visitorId = this.consentStore.cookieName;
|
|
1973
|
+
const { httpRequest: httpRequest2 } = await import("./http-VHOGZ6ZJ.mjs");
|
|
1974
|
+
await httpRequest2(
|
|
1975
|
+
`${base}/consenti/api/v1/consent/${visitorId}`,
|
|
1976
|
+
{ method: "DELETE" },
|
|
1977
|
+
this.config.api?.authToken
|
|
1978
|
+
);
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
console.warn("[Consenti] API consent deletion failed:", err);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
this.consentStore.delete();
|
|
1984
|
+
this.broadcastChannel.send({ type: "consentDeleted" });
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Removes existing consent and immediately re-shows the consent banner so the user
|
|
1988
|
+
* can review and update their preferences.
|
|
1989
|
+
*
|
|
1990
|
+
* Equivalent to calling `deleteConsent()` followed by `showBanner()`, but in a single
|
|
1991
|
+
* method so callers do not need to coordinate the async deletion with the banner display.
|
|
1992
|
+
*
|
|
1993
|
+
* Use this to power "Review my consent" links in footers or privacy dashboards.
|
|
1994
|
+
*/
|
|
1995
|
+
async reConsent() {
|
|
1996
|
+
await this.deleteConsent();
|
|
1997
|
+
this.showBanner();
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Returns the fully resolved profile for the current session, or `null` during the
|
|
2001
|
+
* async init phase (before the profile has been fetched/resolved).
|
|
2002
|
+
*
|
|
2003
|
+
* Plugins can use this to read cookie definitions, banner config, modal categories,
|
|
2004
|
+
* and the profile version without accessing internal state.
|
|
2005
|
+
*/
|
|
2006
|
+
getProfile() {
|
|
2007
|
+
return this.profile;
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Returns the `#consenti-root` container element that wraps all widget DOM,
|
|
2011
|
+
* or `null` before the widget has mounted or after `destroy()`.
|
|
2012
|
+
*
|
|
2013
|
+
* Plugins can use this to append custom elements, apply extra CSS variables,
|
|
2014
|
+
* or observe mutations within the widget container.
|
|
2015
|
+
*/
|
|
2016
|
+
getRootElement() {
|
|
2017
|
+
if (!isClient()) return null;
|
|
2018
|
+
if (this.config.rootEl) {
|
|
2019
|
+
if (typeof this.config.rootEl === "string") {
|
|
2020
|
+
return document.querySelector(this.config.rootEl);
|
|
2021
|
+
}
|
|
2022
|
+
return this.config.rootEl;
|
|
2023
|
+
}
|
|
2024
|
+
return document.getElementById("consenti-root");
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Returns the `#consenti-banner` element when it is present in the DOM, or `null`.
|
|
2028
|
+
* The element may be hidden (`display: none`) — check `bannerVisibility()` for visibility state.
|
|
2029
|
+
*
|
|
2030
|
+
* Useful for adding extra content, custom event listeners, or mutation observers on the banner.
|
|
2031
|
+
*/
|
|
2032
|
+
getBannerElement() {
|
|
2033
|
+
if (!isClient()) return null;
|
|
2034
|
+
return document.getElementById("consenti-banner");
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Returns the `#consenti-modal` element when the modal has been built, or `null`.
|
|
2038
|
+
* The element is built on first `showModal()` call and reused on subsequent opens.
|
|
2039
|
+
*
|
|
2040
|
+
* Useful for injecting extra content into the modal or listening to modal-specific events.
|
|
2041
|
+
*/
|
|
2042
|
+
getModalElement() {
|
|
2043
|
+
if (!isClient()) return null;
|
|
2044
|
+
return document.getElementById("consenti-modal");
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Switches the active locale, re-resolves the profile, and re-renders the widget.
|
|
2048
|
+
* Called by the locale switcher UI when the user selects a different language.
|
|
2049
|
+
*
|
|
2050
|
+
* @param locale - BCP 47 locale code to switch to (e.g. `'fr'`, `'de-AT'`).
|
|
2051
|
+
*/
|
|
2052
|
+
switchLocale(locale) {
|
|
2053
|
+
if (!isClient()) return;
|
|
2054
|
+
this.config.core.locale = locale;
|
|
2055
|
+
this.banner?.remove();
|
|
2056
|
+
if (this.modal?.isOpen()) this.modal.close();
|
|
2057
|
+
this._bannerVariant = null;
|
|
2058
|
+
this.profile = null;
|
|
2059
|
+
this.initialized = false;
|
|
2060
|
+
this.readyPromise = new Promise((resolve) => {
|
|
2061
|
+
this.readyResolve = resolve;
|
|
2062
|
+
});
|
|
2063
|
+
this.init().catch((err) => console.warn("[Consenti] Locale switch error:", err));
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Destroys the widget — removes all DOM elements, disconnects the BroadcastChannel,
|
|
2067
|
+
* and calls `destroy()` on all registered plugins.
|
|
2068
|
+
*
|
|
2069
|
+
* After `destroy()`, the instance should not be used again.
|
|
2070
|
+
*/
|
|
2071
|
+
destroy() {
|
|
2072
|
+
for (const plugin of this.plugins) {
|
|
2073
|
+
try {
|
|
2074
|
+
plugin.destroy();
|
|
2075
|
+
} catch {
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
this.modal?.destroy();
|
|
2079
|
+
this.renderer?.unmount();
|
|
2080
|
+
this.broadcastChannel.disconnect();
|
|
2081
|
+
this.banner = null;
|
|
2082
|
+
this.modal = null;
|
|
2083
|
+
this.renderer = null;
|
|
2084
|
+
this.consentStore = null;
|
|
2085
|
+
this.eventBus = null;
|
|
2086
|
+
this.initialized = false;
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
|
|
2090
|
+
// src/plugins/plugin.base.ts
|
|
2091
|
+
var ConsentiPlugin = class {
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
// src/profile/consenti-profile.ts
|
|
2095
|
+
var nextLocalId = 1e3;
|
|
2096
|
+
var ConsentiProfile = class {
|
|
2097
|
+
/**
|
|
2098
|
+
* @param config - Full profile configuration including cookies, locales, and UI text.
|
|
2099
|
+
*/
|
|
2100
|
+
constructor(config) {
|
|
2101
|
+
this.config = config;
|
|
2102
|
+
this.id = nextLocalId++;
|
|
2103
|
+
registerProfile(this.id, config);
|
|
2104
|
+
}
|
|
2105
|
+
/** Returns the auto-assigned numeric profile ID. Pass this to `core.profileId`. */
|
|
2106
|
+
getId() {
|
|
2107
|
+
return this.id;
|
|
2108
|
+
}
|
|
2109
|
+
/** Returns the raw `ProfileConfig` passed to the constructor. */
|
|
2110
|
+
toJSON() {
|
|
2111
|
+
return this.config;
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
// src/utils/consent-script.ts
|
|
2116
|
+
var ConsentScript = class {
|
|
2117
|
+
constructor(options) {
|
|
2118
|
+
this.options = options;
|
|
2119
|
+
this.scriptEl = null;
|
|
2120
|
+
this.handler = () => this.evaluate();
|
|
2121
|
+
if (!isClient()) return;
|
|
2122
|
+
window.addEventListener("consenti:consentSubmitted", this.handler);
|
|
2123
|
+
this.evaluate();
|
|
2124
|
+
}
|
|
2125
|
+
evaluate() {
|
|
2126
|
+
const consent = this.options.widget.getConsent();
|
|
2127
|
+
const granted = consent?.[this.options.cookieId] === "granted";
|
|
2128
|
+
if (granted && !this.scriptEl) {
|
|
2129
|
+
this.inject();
|
|
2130
|
+
} else if (!granted && this.scriptEl) {
|
|
2131
|
+
this.revoke();
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
inject() {
|
|
2135
|
+
const script = document.createElement("script");
|
|
2136
|
+
if (this.options.src) script.src = this.options.src;
|
|
2137
|
+
if (this.options.innerHTML) script.innerHTML = this.options.innerHTML;
|
|
2138
|
+
for (const [key, value] of Object.entries(this.options.attributes ?? {})) {
|
|
2139
|
+
script.setAttribute(key, value);
|
|
2140
|
+
}
|
|
2141
|
+
document.head.appendChild(script);
|
|
2142
|
+
this.scriptEl = script;
|
|
2143
|
+
this.options.onLoad?.();
|
|
2144
|
+
}
|
|
2145
|
+
revoke() {
|
|
2146
|
+
this.scriptEl?.remove();
|
|
2147
|
+
this.scriptEl = null;
|
|
2148
|
+
this.options.onRevoke?.();
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Removes the event listener and the injected `<script>` element (if present).
|
|
2152
|
+
* Call this when the widget is destroyed or the script is no longer needed.
|
|
2153
|
+
*/
|
|
2154
|
+
destroy() {
|
|
2155
|
+
if (!isClient()) return;
|
|
2156
|
+
window.removeEventListener("consenti:consentSubmitted", this.handler);
|
|
2157
|
+
this.revoke();
|
|
2158
|
+
}
|
|
2159
|
+
};
|
|
2160
|
+
|
|
2161
|
+
// src/utils/cookie-trigger.ts
|
|
2162
|
+
var CookieTrigger = class {
|
|
2163
|
+
constructor(options) {
|
|
2164
|
+
const action = options.action ?? "modal";
|
|
2165
|
+
this.clickHandler = () => {
|
|
2166
|
+
if (action === "modal") options.widget.showModal();
|
|
2167
|
+
else options.widget.showBanner();
|
|
2168
|
+
};
|
|
2169
|
+
if (!options.el) {
|
|
2170
|
+
const btn = document.createElement("button");
|
|
2171
|
+
btn.type = "button";
|
|
2172
|
+
btn.className = "consenti-btn consenti-btn--text consenti-cookie-trigger";
|
|
2173
|
+
btn.textContent = options.label ?? "Cookie Settings";
|
|
2174
|
+
btn.setAttribute("aria-label", options.label ?? "Open cookie settings");
|
|
2175
|
+
this.el = btn;
|
|
2176
|
+
this.created = true;
|
|
2177
|
+
} else if (typeof options.el === "string") {
|
|
2178
|
+
if (!isClient()) {
|
|
2179
|
+
this.el = document.createElement("span");
|
|
2180
|
+
this.created = false;
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
const found = document.querySelector(options.el);
|
|
2184
|
+
if (!found) {
|
|
2185
|
+
throw new Error(
|
|
2186
|
+
`[Consenti] CookieTrigger: element not found for selector "${options.el}"`
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
this.el = found;
|
|
2190
|
+
this.created = false;
|
|
2191
|
+
} else {
|
|
2192
|
+
this.el = options.el;
|
|
2193
|
+
this.created = false;
|
|
2194
|
+
}
|
|
2195
|
+
this.el.addEventListener("click", this.clickHandler);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Returns the trigger element.
|
|
2199
|
+
* When no `el` was provided, append the returned element to the desired location
|
|
2200
|
+
* in the DOM after construction.
|
|
2201
|
+
*/
|
|
2202
|
+
getElement() {
|
|
2203
|
+
return this.el;
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Removes the click handler and, if this instance auto-created the button,
|
|
2207
|
+
* removes it from the DOM.
|
|
2208
|
+
*/
|
|
2209
|
+
destroy() {
|
|
2210
|
+
this.el.removeEventListener("click", this.clickHandler);
|
|
2211
|
+
if (this.created) this.el.remove();
|
|
2212
|
+
}
|
|
2213
|
+
};
|
|
2214
|
+
export {
|
|
2215
|
+
ConsentScript,
|
|
2216
|
+
ConsentiPlugin,
|
|
2217
|
+
ConsentiProfile,
|
|
2218
|
+
ConsentiSetup,
|
|
2219
|
+
CookieTrigger
|
|
2220
|
+
};
|
|
2221
|
+
//# sourceMappingURL=index.mjs.map
|