@consenti/ui 0.1.0 → 0.3.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.
Files changed (43) hide show
  1. package/README.md +548 -151
  2. package/dist/angular.d.cts +486 -111
  3. package/dist/angular.d.ts +486 -111
  4. package/dist/angular.js +1 -73
  5. package/dist/angular.js.map +1 -1
  6. package/dist/angular.mjs +1 -52
  7. package/dist/angular.mjs.map +1 -1
  8. package/dist/chunk-3EXCD24H.mjs +2 -0
  9. package/dist/{chunk-ARALKXMB.mjs.map → chunk-3EXCD24H.mjs.map} +1 -1
  10. package/dist/chunk-J4BKXUD6.mjs +2 -0
  11. package/dist/chunk-J4BKXUD6.mjs.map +1 -0
  12. package/dist/hmac-UKMRMKN6.mjs +2 -0
  13. package/dist/{hmac-N6H7F2K2.mjs.map → hmac-UKMRMKN6.mjs.map} +1 -1
  14. package/dist/http-AQVVUCX6.mjs +2 -0
  15. package/dist/index.css +1 -1
  16. package/dist/index.d.ts +806 -103
  17. package/dist/index.mjs +1 -2220
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/index.umd.js +1 -2304
  20. package/dist/index.umd.js.map +1 -1
  21. package/dist/profiles-OEZAKEWL.mjs +2 -0
  22. package/dist/profiles-OEZAKEWL.mjs.map +1 -0
  23. package/dist/react.d.cts +470 -89
  24. package/dist/react.d.ts +470 -89
  25. package/dist/react.js +1 -60
  26. package/dist/react.js.map +1 -1
  27. package/dist/react.mjs +1 -39
  28. package/dist/react.mjs.map +1 -1
  29. package/dist/testing.js +1 -94
  30. package/dist/testing.js.map +1 -1
  31. package/dist/testing.mjs +1 -73
  32. package/dist/testing.mjs.map +1 -1
  33. package/dist/vue.d.cts +473 -82
  34. package/dist/vue.d.ts +473 -82
  35. package/dist/vue.js +1 -72
  36. package/dist/vue.js.map +1 -1
  37. package/dist/vue.mjs +1 -51
  38. package/dist/vue.mjs.map +1 -1
  39. package/package.json +15 -6
  40. package/dist/chunk-ARALKXMB.mjs +0 -20
  41. package/dist/hmac-N6H7F2K2.mjs +0 -26
  42. package/dist/http-VHOGZ6ZJ.mjs +0 -7
  43. /package/dist/{http-VHOGZ6ZJ.mjs.map → http-AQVVUCX6.mjs.map} +0 -0
package/README.md CHANGED
@@ -8,12 +8,13 @@ Zero-dependency, GDPR-compliant cookie consent widget for any web framework.
8
8
 
9
9
  ---
10
10
 
11
- ## Why this package?
11
+ ## Why Consenti?
12
12
 
13
13
  - **Zero runtime dependencies** — browser built-ins only (`fetch`, `crypto.subtle`, `BroadcastChannel`, `CustomEvent`, `document.cookie`, `localStorage`)
14
- - **Fully standalone or API-connected** — works offline with a local profile, or connects to `@consenti/api` for dashboard-managed profiles
15
- - **Multi-regulation** — GDPR, UK-GDPR, CCPA, CPRA, LGPD, DPDPA, PIPEDA, POPIA, PDPA-TH, APPI, KVKK
16
- - **GPC aware** — Global Privacy Control signal detection with `true` / `'strict'` modes
14
+ - **Fully standalone or API-connected** — works offline with pre-built profiles, or connects to `@consenti/api` for dashboard-managed profiles
15
+ - **Multi-regulation** — GDPR, CCPA, CPRA, LGPD, DPDPA, PIPL, and more via 8 built-in compliance groups
16
+ - **Automatic geo-resolution** — detects visitor jurisdiction client-side (timezone + language) or server-side (`/resolve-profile`)
17
+ - **GPC aware** — Global Privacy Control signal detection with `'honor'` / `'strict'` / `'ignore'` modes per profile
17
18
  - **GTM / Google Consent Mode v2** — built-in `dataLayer` integration
18
19
  - **SSR-safe** — all browser API access is guarded; `new ConsentiSetup()` during SSR is a silent no-op
19
20
  - **Fully typed** — ships TypeScript definitions; no `@types` needed
@@ -48,7 +49,7 @@ import '@consenti/ui/dist/index.css'
48
49
  <script src="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.umd.js"></script>
49
50
  <script>
50
51
  const { ConsentiSetup } = ConsentiUI
51
- new ConsentiSetup({ core: { regulation: 'gdpr' } })
52
+ new ConsentiSetup({ compliance: { type: 'opt-in' } })
52
53
  </script>
53
54
  ```
54
55
 
@@ -57,7 +58,7 @@ import '@consenti/ui/dist/index.css'
57
58
  ```html
58
59
  <script type="module">
59
60
  import { ConsentiSetup } from 'https://esm.sh/@consenti/ui'
60
- new ConsentiSetup({ core: { regulation: 'gdpr' } })
61
+ new ConsentiSetup({ compliance: { type: 'opt-in' } })
61
62
  </script>
62
63
  ```
63
64
 
@@ -74,8 +75,16 @@ import '@consenti/ui/dist/index.css'
74
75
  ## Quick Start
75
76
 
76
77
  ```ts
77
- // Simplest possible — built-in GDPR profile, no backend required
78
- new ConsentiSetup({ core: { regulation: 'gdpr' } })
78
+ // Simplest possible — auto-detects jurisdiction from browser timezone + language
79
+ new ConsentiSetup({})
80
+
81
+ // Fixed GDPR (EU opt-in) profile, no backend required
82
+ new ConsentiSetup({ compliance: { type: 'opt-in' } })
83
+
84
+ // API-backed — server geo-resolves the right profile per visitor
85
+ new ConsentiSetup({
86
+ api: { enabled: true, baseUrl: 'https://your-backend.com' },
87
+ })
79
88
  ```
80
89
 
81
90
  A consent banner appears on first visit. Everything below is optional.
@@ -101,20 +110,27 @@ import { ConsentiSetup } from '@consenti/ui'
101
110
  import '@consenti/ui/dist/index.css'
102
111
 
103
112
  const widget = new ConsentiSetup({
104
- // ── Required ────────────────────────────────────────────────────────────────
113
+ // ── Core (optional) ──────────────────────────────────────────────────────────
105
114
  core: {
106
- profileId: 1, // 0 = built-in default; >0 = local or API profile
107
- regulation: 'gdpr', // controls opt-in / opt-out behaviour
108
- locale: 'en', // BCP 47; falls back to language prefix, then 'en'
109
- autoHonorGPC: true, // false | true | 'strict'
110
- storage: 'cookie', // 'cookie' | 'localStorage'
111
- cookieDomains: '.example.com',
112
- privacyPolicyUrl: '/privacy',
113
- signCookies: true,
114
- cookieSigningKey: 'min-32-char-secret', // client-only mode; use API mode in production
115
- allowReceipt: true,
116
- disableCssTemplate: false,
115
+ tenantId: 'acme', // identifies your tenant's profiles; default: 'default'
116
+ locale: 'en', // BCP 47; 'auto' = navigator.language; default: 'auto'
117
+ dir: 'auto', // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale
118
+ // (ar/he/fa/ur/ps/sd/ug/yi → rtl); default: 'auto'
119
+ storage: 'cookie', // 'cookie' | 'localStorage'; default: 'cookie'
120
+ cookieDomains: '.example.com', // comma-separated; first entry used as Domain attribute
121
+ cookieSigningKey: 'min-32-char-secret', // HMAC-SHA256 signing; implicit from presence
122
+ allowReceipt: true, // allow consent receipt download; default: false
123
+ disableCssTemplate: false, // skip all style injection; default: false
117
124
  userId: 'server-assigned-uuid', // authenticated users — enables cross-device sync
125
+
126
+ // Pre-built profile fallback
127
+ usePrebuiltProfiles: 'all', // 'all' (default) | ['opt-in', 'opt-out', ...] (non-empty)
128
+ // controls which of the 8 built-in profiles are available
129
+ // as fallbacks when the API is unavailable
130
+ cacheResolvedProfiles: true, // cache /resolve-profile response in sessionStorage; default: true
131
+ console: ['error'], // log levels emitted: 'info' | 'log' | 'warning' | 'error'
132
+ // default: ['error'] only — no noise in production
133
+
118
134
  theme: {
119
135
  bgColor: '#ffffff',
120
136
  textColor: '#1a1a1a',
@@ -136,6 +152,35 @@ const widget = new ConsentiSetup({
136
152
  },
137
153
  },
138
154
 
155
+ // ── Compliance routing (optional) ─────────────────────────────────────────
156
+ compliance: {
157
+ // 'auto' → geo-resolve per visitor (default)
158
+ // api.enabled=true → server resolves via /resolve-profile
159
+ // api.enabled=false → resolves client-side from timezone + language
160
+ // ComplianceGroupId → fixed group for all visitors (skip geo)
161
+ // localProfileType → use a locally registered ConsentiProfile
162
+ type: 'auto',
163
+
164
+ // Client-side geo resolver (used only when api.enabled = false)
165
+ // 'default' → browser timezone + navigator.language heuristic (built-in, zero deps)
166
+ // WidgetCountryResolverFn → custom async function: () => Promise<{ country, region, confidence }>
167
+ geoDataProvider: 'default',
168
+
169
+ // Age gate (optional) — see "Age Gate" section below
170
+ ageGate: {
171
+ enabled: false,
172
+ minimumAge: 13,
173
+ requireParentalConsent: false,
174
+ },
175
+
176
+ // TCF (IAB Transparency & Consent Framework) client stub (optional) — see "TCF" section below
177
+ tcf: {
178
+ enabled: false,
179
+ cmpId: 0, // must match the cmpId configured on the backend (TcfConfig)
180
+ cmpVersion: 1,
181
+ },
182
+ },
183
+
139
184
  // ── Mount point (optional) ────────────────────────────────────────────────
140
185
  rootEl: '#my-consent-wrapper', // CSS selector or HTMLElement; default: appends to body
141
186
 
@@ -147,6 +192,9 @@ const widget = new ConsentiSetup({
147
192
  enabled: true,
148
193
  baseUrl: 'https://your-site.com', // default: window.location.origin
149
194
  authToken: '', // sent as Authorization: Bearer <token>
195
+ tenantId: 'acme', // overrides core.tenantId for API calls
196
+ complianceGroup: 'opt-in', // pin a specific group on the API side (Scenario 2B)
197
+ trustDomain: false, // true = bypass allowedOrigins check (use in dev only)
150
198
  },
151
199
 
152
200
  // ── GTM / Google Consent Mode v2 (optional) ───────────────────────────────
@@ -170,47 +218,190 @@ const widget = new ConsentiSetup({
170
218
  })
171
219
  ```
172
220
 
221
+ ### `profileOverride` — deep-merge and delete semantics
222
+
223
+ `profileOverride` (and `widget.setProfile()` at runtime) is deep-merged onto the resolved profile
224
+ — server-fetched, pre-built, or locally registered — before anything renders. Merge rules:
225
+
226
+ - Omitting a key (or setting it to `undefined`) leaves the resolved profile's value untouched.
227
+ - An object value merges recursively, key by key — you only need to specify what differs.
228
+ - An array value replaces/merges by index against the base array.
229
+ - Setting a key to `null` **deletes it** from the merged result (JSON Merge Patch / RFC 7396
230
+ semantics). This is the way to remove a single entry from a keyed map — a cookie category, a
231
+ parameter — without having to know or repeat the rest of that map's contents:
232
+
233
+ ```ts
234
+ profileOverride: {
235
+ preferenceModal: {
236
+ categories: { marketing: null }, // removes the 'marketing' category entirely
237
+ },
238
+ },
239
+ ```
240
+
241
+ `{ marketing: undefined }` or `{ marketing: {} }` do **not** delete the entry — the former is a
242
+ no-op (same as omitting the key), the latter merges an empty object onto the existing category,
243
+ changing nothing. Only an explicit `null` removes it.
244
+
173
245
  ---
174
246
 
175
- ## Regulations
176
-
177
- | Value | Region | Model | Notes |
178
- |--------------|---------------------|---------------------|------------------------------------------------------------------------|
179
- | `'gdpr'` | EU / EEA | Opt-in | Default. Banner on first visit; all non-mandatory cookies denied until granted. |
180
- | `'uk-gdpr'` | UK | Opt-in | Same model as GDPR; ICO-enforced; age gate at 13. |
181
- | `'ccpa'` | California (US) | Opt-out | All cookies default to `'granted'`; consent written silently; no banner. |
182
- | `'cpra'` | California (US) | Opt-out / Opt-in | Supersedes CCPA. Opt-out for sale/sharing; opt-in for sensitive data. GPC triggers Do Not Sell + Do Not Share. |
183
- | `'lgpd'` | Brazil | Opt-in | 10 lawful bases; ANPD-enforced; parental consent gate under-12. |
184
- | `'dpdpa'` | India | Opt-in | Fiduciary name + grievance officer in modal; GPC ignored. |
185
- | `'pipeda'` | Canada | Opt-in | PIPEDA + Quebec Law 25 (GDPR-aligned). |
186
- | `'popia'` | South Africa | Opt-in | 8 processing conditions; Information Regulator-enforced. |
187
- | `'pdpa-th'` | Thailand | Opt-in | Cross-border transfer rules; PDPC-enforced. |
188
- | `'appi'` | Japan | Opt-in / Opt-out | Opt-in for sensitive data and overseas transfers; opt-out for general third-party sharing. |
189
- | `'kvkk'` | Turkey | Opt-in | Explicit consent for sensitive personal data; KVK Board-enforced. |
247
+ ## Compliance Groups
248
+
249
+ Consenti ships 8 pre-built English profiles, one per compliance group. The active group is resolved automatically per visitor (API or client-side) or fixed globally via `compliance.type`.
250
+
251
+ | Group | Region / Law | Model | GPC Default |
252
+ |----------------------------|-------------------------------|------------|-------------|
253
+ | `'opt-in'` | EU / EEA — GDPR | Opt-in | `'honor'` |
254
+ | `'opt-out'` | California — CCPA | Opt-out | `'honor'` |
255
+ | `'opt-out-strict'` | California — CPRA | Strict opt-out | `'strict'` |
256
+ | `'opt-in-dpdpa'` | India — DPDPA | Opt-in | `'honor'` |
257
+ | `'opt-in-china'` | China — PIPL | Opt-in | `'ignore'` |
258
+ | `'opt-in-brazil'` | Brazil — LGPD | Opt-in | `'honor'` |
259
+ | `'general-privacy-consent'`| Global / general | Opt-in | `'honor'` |
260
+ | `'notice-only'` | Informational | Notice | `'ignore'` |
261
+
262
+ ### Fixed group (no geo-routing)
263
+
264
+ ```ts
265
+ // All visitors see the GDPR opt-in banner
266
+ new ConsentiSetup({ compliance: { type: 'opt-in' } })
267
+
268
+ // All visitors see the CCPA opt-out notice
269
+ new ConsentiSetup({ compliance: { type: 'opt-out' } })
270
+ ```
271
+
272
+ ### Auto geo-routing (default)
273
+
274
+ ```ts
275
+ // Client-side: timezone + navigator.language → compliance group
276
+ new ConsentiSetup({ compliance: { type: 'auto' } })
277
+
278
+ // Server-side: /resolve-profile → right profile per visitor country
279
+ new ConsentiSetup({
280
+ api: { enabled: true, baseUrl: 'https://consent.example.com' },
281
+ compliance: { type: 'auto' },
282
+ })
283
+ ```
284
+
285
+ ---
286
+
287
+ ## Profile Resolution Scenarios
288
+
289
+ | Scenario | When | How |
290
+ |---|---|---|
291
+ | **1A** | `api.enabled = false`, `compliance.type = 'auto'` | Timezone + language → group → pre-built profile |
292
+ | **1B** | `api.enabled = false`, `compliance.type = ComplianceGroupId` | Direct pre-built profile load |
293
+ | **2A** | `api.enabled = true`, `compliance.type = 'auto'` | `GET /resolve-profile?tz&lang&locale&tenantId` → `filePath` → static JSON |
294
+ | **2B** | `api.enabled = true`, `compliance.type = ComplianceGroupId` | `GET /profiles/:tenantId/:group/:locale` |
295
+ | **Local** | `compliance.type = localKey` + registered `ConsentiProfile` | Locally registered profile |
296
+ | **Fallback** | Any fetch failure | Pre-built profile → `DEFAULT_PROFILE` |
297
+
298
+ The `/resolve-profile` response URL is cached in `sessionStorage` for 1 hour per tab (`core.cacheResolvedProfiles`). The profile JSON itself is HTTP-cached via `Cache-Control: public, max-age=3600` on the server.
299
+
300
+ ### Domain allowlist
301
+
302
+ If a profile's `allowedOrigins` list is configured on the server, the widget checks `window.location.origin` against it before using the profile. A mismatch falls back to the pre-built profile for that group.
303
+
304
+ Set `api.trustDomain: true` to bypass this check (useful for localhost / dev environments where `allowedOrigins` contains only production domains).
190
305
 
191
306
  ---
192
307
 
193
308
  ## GPC — Global Privacy Control
194
309
 
310
+ GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config profileOverrides overrides the profile value when explicitly set.
311
+
312
+ ---
313
+
314
+ ## Age Gate
315
+
316
+ Set `compliance.ageGate.enabled: true` to require age confirmation before the visitor sees any
317
+ other consent UI (banner, GPC, CCPA opt-out — all of it waits behind the age gate on first visit):
318
+
195
319
  ```ts
196
320
  new ConsentiSetup({
197
- core: {
198
- regulation: 'gdpr',
199
- autoHonorGPC: true, // pre-deny 'listenGpc' cookies and show GPC banner variant
200
- // autoHonorGPC: 'strict' // pre-deny silently — no banner shown at all
321
+ compliance: {
322
+ ageGate: { enabled: true, minimumAge: 16, requireParentalConsent: true },
323
+ },
324
+ })
325
+ ```
326
+
327
+ - **Confirmed** (visitor is old enough) → the normal banner/GPC/CCPA flow proceeds exactly as if
328
+ the age gate didn't exist, and every consent submission from then on carries `ageVerified: true`.
329
+ - **Declined, `requireParentalConsent: false`** → a deny-all consent is submitted immediately
330
+ (mandatory/strictly-necessary cookies still granted), no banner shown, `ageVerified: false`.
331
+ - **Declined, `requireParentalConsent: true`** → same deny-all submission, plus a
332
+ `parentalConsentToken` is generated and a `consenti:parentalConsentRequired` event fires
333
+ carrying it. There's no email-verification pipeline built into this package — the event is the
334
+ hook for wiring your own out-of-band parental-consent process (once verified externally, call
335
+ `PUT /consent/:visitorId` yourself to update the record).
336
+
337
+ The prompt only asks once per visitor per profile — it doesn't re-appear once any consent record
338
+ exists (same "already decided" check the banner itself uses).
339
+
340
+ ---
341
+
342
+ ## TCF — IAB Transparency & Consent Framework
343
+
344
+ Set `compliance.tcf.enabled: true` to install `window.__tcfapi`, the standard IAB entry point
345
+ third-party ad-tech scripts call to read consent (`ping`, `getTCData`, `addEventListener`,
346
+ `removeEventListener`):
347
+
348
+ ```ts
349
+ new ConsentiSetup({
350
+ compliance: {
351
+ tcf: { enabled: true, cmpId: 280, cmpVersion: 1 }, // cmpId must match the backend's TcfConfig
201
352
  },
202
353
  })
203
354
  ```
204
355
 
205
- Cookies with `listenGpc: true` in the profile are automatically denied when the signal is detected.
356
+ The stub follows the standard queue-command convention, so it's safe regardless of load order
357
+ relative to third-party scripts that call `__tcfapi` before Consenti has initialized. Only one CMP
358
+ may own `window.__tcfapi` per page — if it's already set (a real CMP, or another `ConsentiSetup`
359
+ instance on a multi-profile page), this stub does not overwrite it.
360
+
361
+ This is a simplified implementation: `tcString` is a base64url-encoded JSON payload (the same
362
+ format the backend's `tcfString` uses), not the full IAB binary bitfield encoding, and vendor-list
363
+ metadata (`gvlVersion`, `publisherCC`) is a placeholder since the widget doesn't fetch the GVL
364
+ client-side. For full IAB TCF v2.2 compliance, use the `iabtcf-core` npm package instead.
365
+
366
+ ---
367
+
368
+ ## RTL / Text Direction
369
+
370
+ `core.dir` controls the banner/modal's reading direction:
371
+
372
+ ```ts
373
+ new ConsentiSetup({ core: { locale: 'ar', dir: 'auto' } }) // → rtl, derived from locale
374
+ new ConsentiSetup({ core: { dir: 'rtl' } }) // explicit, independent of locale
375
+ ```
376
+
377
+ `'auto'` (the default) maps a known RTL-language prefix (`ar`, `he`, `fa`, `ur`, `ps`, `sd`, `ug`,
378
+ `yi`) to `rtl`; anything else is `ltr`. The `dir` attribute is set on the widget's root element, so
379
+ browser-native mirroring (flex layout, `text-align: start/end`) and the widget's own `[dir="rtl"]`
380
+ CSS overrides (toggle switches, locale switcher, close button) apply automatically — no separate
381
+ RTL stylesheet needed. Explicit banner/modal position variants (`left-bottom`, `right-bottom`,
382
+ modal `left`/`right` slide-in) are **not** mirrored — those are screen positions you chose
383
+ deliberately, independent of text direction.
206
384
 
207
385
  ---
208
386
 
209
387
  ## Profiles
210
388
 
211
- ### Built-in default profile
389
+ ### Pre-built profiles (no backend)
390
+
391
+ The widget includes 8 pre-built English profiles as dynamic-import chunks. Only the matched chunk downloads at runtime.
392
+
393
+ ```ts
394
+ // GDPR banner — no server needed
395
+ new ConsentiSetup({ compliance: { type: 'opt-in' } })
212
396
 
213
- When `core.profileId` is `0` or omitted, the widget uses a built-in English GDPR profile covering the five Google Consent Mode v2 purposes. No configuration needed.
397
+ // CCPA notice — no server needed
398
+ new ConsentiSetup({ compliance: { type: 'opt-out' } })
399
+
400
+ // Restrict which pre-built profiles are available as fallbacks
401
+ new ConsentiSetup({
402
+ core: { usePrebuiltProfiles: ['opt-in', 'opt-out'] },
403
+ })
404
+ ```
214
405
 
215
406
  ### Local profile (no backend)
216
407
 
@@ -221,12 +412,13 @@ import { ConsentiProfile, ConsentiSetup } from '@consenti/ui'
221
412
 
222
413
  const profile = new ConsentiProfile({
223
414
  defaultLocale: 'en',
224
- cookies: [
225
- { id: 'necessary', mandatory: true, expiry: 365 },
226
- { id: 'analytics', listenGpc: true, expiry: 365 },
227
- { id: 'marketing', listenGpc: true, expiry: 365, cpraCategory: 'sale' },
228
- // cpraCategory: 'sale' | 'sharing' | 'sensitive' — used when regulation: 'cpra'
229
- ],
415
+ complianceGroup: 'opt-in',
416
+ cookies: {
417
+ necessary: { purpose: 'necessary', listenGpc: false },
418
+ analytics: { purpose: 'analytics', listenGpc: true },
419
+ marketing: { purpose: 'marketing', listenGpc: true, cpraCategory: 'sale' },
420
+ // cpraCategory: 'sale' | 'sharing' | 'sensitive' — used by CPRA group
421
+ },
230
422
  translations: {
231
423
  en: {
232
424
  mainBanner: {
@@ -237,14 +429,14 @@ const profile = new ConsentiProfile({
237
429
  heading: 'We value your privacy',
238
430
  headingTag: 'h2', // HTML tag for the heading; default 'h2'
239
431
  htmlText: 'We use cookies to improve your experience.',
240
- buttons: [
241
- { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
432
+ buttons: {
433
+ // key = machine id, rendered as the button's DOM id: `consenti-btn-{id}`, for targeting a specific button
242
434
  // cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
243
- { text: 'Reject All', style: 'secondary', action: 'custom', cookies: '!' },
244
- { text: 'Manage Preferences', style: 'secondary', action: 'manage' },
245
- { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
246
- // action: 'link' + url — renders as a text link below the banner
247
- ],
435
+ 'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
436
+ 'reject-optional': { text: 'Reject Optional', style: 'secondary', action: 'custom', cookies: '!' },
437
+ customize: { text: 'Customize', style: 'secondary', action: 'manage' },
438
+ 'privacy-policy': { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
439
+ },
248
440
  },
249
441
  gpcBanner: { // shown instead of mainBanner when GPC detected
250
442
  position: 'bottom',
@@ -252,14 +444,14 @@ const profile = new ConsentiProfile({
252
444
  headingTag: 'h2',
253
445
  showLocaleSwitcher: false,
254
446
  htmlText: "Your browser's GPC signal was detected. Ad cookies have been pre-denied.",
255
- buttons: [
256
- { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
257
- { text: 'Manage Preferences', style: 'secondary', action: 'manage' },
258
- ],
447
+ buttons: {
448
+ understood: { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
449
+ customize: { text: 'Customize', style: 'secondary', action: 'manage' },
450
+ },
259
451
  },
260
452
  preferenceModal: {
261
453
  heading: 'Cookie Preferences',
262
- headingTag: 'h2', // HTML tag for the modal heading; default 'h2'
454
+ headingTag: 'h2',
263
455
  subheading: 'Choose which cookies you allow.',
264
456
  htmlText: 'We use different types of cookies. You can enable or disable each category below.',
265
457
  position: 'center', // 'left' | 'right' | 'center'
@@ -267,35 +459,33 @@ const profile = new ConsentiProfile({
267
459
  showLocaleSwitcher: false,
268
460
  persistent: false, // true = cannot dismiss by clicking outside
269
461
  overlayOpacity: 50,
270
- buttons: [
271
- { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
272
- { text: 'Save Preferences', style: 'primary', action: 'submit' },
273
- { text: 'Reject All', style: 'text', action: 'custom', cookies: '!' },
274
- ],
275
- categories: [
276
- {
277
- id: 'cat-necessary',
462
+ mobileFullScreenBreakpoint: 576,
463
+ buttons: {
464
+ 'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
465
+ 'save-preferences': { text: 'Save Preferences', style: 'primary', action: 'submit' },
466
+ 'reject-optional': { text: 'Reject Optional', style: 'text', action: 'custom', cookies: '!' },
467
+ },
468
+ categories: {
469
+ necessary: {
278
470
  heading: 'Strictly Necessary',
279
- headingTag: 'h3', // HTML tag for the category heading; default 'h3'
471
+ headingTag: 'h3',
280
472
  htmlText: 'Required for the site to function. <strong>Cannot be disabled.</strong>',
281
- mandatory: true,
473
+ legalBasis: 'mandatory',
282
474
  cookies: ['necessary'],
283
475
  },
284
- {
285
- id: 'cat-analytics',
476
+ analytics: {
286
477
  heading: 'Analytics',
287
478
  htmlText: 'Helps us understand how visitors use the site.',
288
- type: 'consent', // 'consent' | 'legitimate_interest'
289
- // type: 'legitimate_interest' — refusal stored as 'objected' instead of 'denied'
479
+ legalBasis: 'consent', // 'mandatory' | 'consent' | 'legitimate_interest'
290
480
  cookies: ['analytics'],
291
481
  },
292
- {
293
- id: 'cat-marketing',
482
+ marketing: {
294
483
  heading: 'Marketing',
295
484
  htmlText: 'Used to personalise ads and measure campaign performance.',
485
+ legalBasis: 'consent',
296
486
  cookies: ['marketing'],
297
487
  },
298
- ],
488
+ },
299
489
  },
300
490
  },
301
491
  fr: {
@@ -307,57 +497,137 @@ const profile = new ConsentiProfile({
307
497
  })
308
498
 
309
499
  new ConsentiSetup({
310
- core: {
311
- profileId: profile.getId(), // always use .getId() — never hardcode the ID
312
- regulation: 'gdpr',
313
- locale: 'fr',
314
- },
500
+ compliance: { type: profile.getComplianceGroup() },
315
501
  })
316
502
  ```
317
503
 
318
- > **Note:** Local profile IDs are auto-assigned starting from `1000` to avoid collisions with API profile IDs (which start from `1`). Always call `profile.getId()`.
319
-
320
504
  ### API profile (with backend)
321
505
 
322
506
  ```ts
323
507
  new ConsentiSetup({
324
- core: { profileId: 3, regulation: 'gdpr', locale: 'de' },
325
508
  api: {
326
509
  enabled: true,
327
510
  baseUrl: 'https://consent.example.com',
511
+ tenantId: 'acme',
328
512
  },
513
+ // compliance.type: 'auto' resolves the right profile per visitor country
329
514
  })
330
515
  ```
331
516
 
332
- The widget fetches the profile from the API and falls back to a local profile (or the built-in default) if the request fails.
517
+ The widget calls `GET /resolve-profile?tz&lang&locale&tenantId`, receives the profile file path, then fetches that static JSON directly. Falls back to pre-built profile if the API is unavailable.
333
518
 
334
519
  ---
335
520
 
336
521
  ## Cookie Format
337
522
 
338
- Consent is persisted as a single cookie named `consenti_{profileId}`.
523
+ Consent is persisted as a single cookie named `consenti_data` with a compact JSON value:
339
524
 
525
+ ```json
526
+ {"s":1,"v":2,"i":"5huf-…","u":"","t":1751692400,"g":0,"p":0,"c":{"analytics_storage":"g","ad_storage":"d"}}
340
527
  ```
341
- t:{unixTimestamp}::{cookieId}:{status}|{cookieId}:{status}|…[::sig:{hmac}]
528
+
529
+ | Field | Type | Meaning |
530
+ |---|---|---|
531
+ | `s` | number | Profile ID |
532
+ | `v` | number | Profile version |
533
+ | `i` | string | Per-submission UUID (consent receipt ID) |
534
+ | `u` | string | Logged-in user ID (`""` for anonymous) |
535
+ | `t` | number | Unix timestamp of consent |
536
+ | `g` | 0 \| 1 | GPC signal detected |
537
+ | `p` | number | Consent source: 0 = user click, 1 = widget method |
538
+ | `c` | object | Consent map: cookie ID → `"g"` (granted), `"o"` (objected), `"d"` (denied) |
539
+
540
+ When the widget is upgraded from an older version, any existing `consenti_{profileId}` cookie is automatically read, migrated to the new format, and deleted — no consent loss occurs.
541
+
542
+ The consent cookie lifetime equals the shortest `cookie.expiry` value across all profile cookies (in days, converted to `max-age` seconds). When that cookie expires the browser deletes it, the widget sees no consent, and re-shows the banner.
543
+
544
+ ---
545
+
546
+ ## Display Features
547
+
548
+ ### Footer Metadata
549
+
550
+ When `showFooterMetadata: true` is set in the resolved profile, a metadata strip is injected inside the banner and modal after the main content. It shows the visitor's Consent ID (truncated UUID), Consent Date, Profile Version, and a "Privacy Settings" button that opens the preference modal.
551
+
552
+ Configured in the dashboard (ProfileEditor → Step 1) or via `profileOverride`:
553
+
554
+ ```ts
555
+ new ConsentiSetup({
556
+ compliance: { type: 'opt-in' },
557
+ profileOverride: { showFooterMetadata: true },
558
+ })
342
559
  ```
343
560
 
344
- Example:
561
+ ### Enhanced Accessibility
562
+
563
+ When `enhanceAccessibility: true` is set in the resolved profile, the class `.consenti--enhanced-a11y` is added to the root element. This applies:
564
+
565
+ - Minimum 44 × 44 px hit area on all buttons (WCAG 2.1 SC 2.5.5)
566
+ - 3 px solid focus ring (`outline-offset: 2px`) on all focusable elements
567
+ - No impact on default styling — opt-in only
568
+
569
+ ```ts
570
+ profileOverride: { enhanceAccessibility: true }
345
571
  ```
346
- t:1782133054::analytics:granted|marketing:denied|necessary:granted::sig:abc123
572
+
573
+ ### Responsive Button Stacking (`stackButtonsOnBreakpoint`)
574
+
575
+ Set `mainBanner.stackButtonsOnBreakpoint` (and/or `gpcBanner.stackButtonsOnBreakpoint`) to a pixel width. Below that breakpoint the banner's buttons stack vertically and stretch to full width — useful for narrow mobile screens where 3+ buttons overflow.
576
+
577
+ ```ts
578
+ profileOverride: {
579
+ mainBanner: { stackButtonsOnBreakpoint: 576 }, // px; 0 or undefined = disabled
580
+ }
581
+ ```
582
+
583
+ A scoped `<style>` element containing a single `@media (max-width: Npx)` rule is injected into the root element — no external stylesheet required, and it's removed on `destroy()`.
584
+
585
+ ### Modal Focus Trap (`trapFocus`)
586
+
587
+ When `preferenceModal.trapFocus: true`, keyboard Tab / Shift+Tab navigation is confined within the consent modal while it is open. `Escape` closes the modal and returns focus to the triggering element.
588
+
589
+ ```ts
590
+ profileOverride: {
591
+ preferenceModal: { trapFocus: true },
592
+ }
347
593
  ```
348
594
 
349
595
  ---
350
596
 
597
+ ## Debugging
598
+
599
+ ```ts
600
+ new ConsentiSetup({
601
+ core: {
602
+ console: ['error', 'warning', 'info'], // add 'log' for verbose output
603
+ },
604
+ })
605
+ ```
606
+
607
+ The default is `['error']` only — no noise in production. Add levels when investigating profile resolution, GPC behavior, or domain allowlist failures. All Consenti log lines are prefixed `[Consenti]`.
608
+
609
+ ---
610
+
351
611
  ## Widget API Methods
352
612
 
353
613
  ```ts
354
- const widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
614
+ const widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
355
615
 
356
616
  // Consent state
357
- widget.hasConsent() // boolean — true if valid consent record exists
358
- widget.getConsent() // Record<string, 'granted'|'denied'|'objected'> | null
359
- widget.getGTMConsent() // consent in GTM / Google Consent Mode v2 format
360
- widget.getConsentDate() // Date | false — time of last submission
617
+ widget.hasConsent() // boolean — true if valid consent record exists
618
+ widget.getConsent() // Record<string, 'granted'|'denied'|'objected'> | null
619
+ widget.getConsent('google-gtm') // Google Consent Mode v2 format
620
+ widget.getConsent('category') // { necessary, functional, preferences, analytics, marketing }
621
+ widget.getConsent('adobe') // { analytics, target, manager, optimizer }
622
+ widget.getConsent('meta') // { pixel, api, plugins, facebookLogin }
623
+ widget.getConsent('microsoft-clarity') // { session, heatmaps, performance }
624
+ widget.getConsent('twilio-segment') // { identify, page, track, group, alias }
625
+ widget.getGTMConsent() // @deprecated — use getConsent('google-gtm')
626
+ widget.getConsentDate() // Date | false — time of last submission
627
+ widget.isCookieGranted('analytics') // boolean — true if that cookie is 'granted'
628
+ widget.isCookieGranted('analytics', true)// 'granted' | 'denied' | 'objected' | false
629
+ widget.isCategoryGranted('cat-analytics') // boolean — true if ALL cookies in category are granted
630
+ widget.isCategoryGranted('cat-analytics', true) // [{ analytics: 'granted' }, { pixel: 'denied' }]
361
631
 
362
632
  // Visibility
363
633
  widget.showBanner(gpc?) // show main banner (or GPC variant)
@@ -367,6 +637,12 @@ widget.hideModal() // close preference modal
367
637
  widget.bannerVisibility() // 'main' | 'gpc' | false
368
638
  widget.modalVisibility() // 'preference' | false
369
639
 
640
+ // Bulk consent actions
641
+ widget.grantAll() // grant all cookies and dismiss banner
642
+ widget.grantAll(true) // grant only mandatory; deny everything else
643
+ widget.denyAll() // deny non-mandatory; mandatory stay 'granted'
644
+ widget.denyAll(true) // deny all including mandatory (logs a warning)
645
+
370
646
  // Actions
371
647
  widget.init() // manually start init (when autoInit: false)
372
648
  widget.onReady(callback) // called once widget is fully initialised
@@ -374,15 +650,61 @@ widget.switchLocale(locale) // switch locale and re-render (e.g. 'fr', 'de-
374
650
  widget.submitConsent(consent) // programmatically submit consent values
375
651
  widget.deleteConsent() // delete consent record (cookie + backend if enabled)
376
652
  widget.reConsent() // delete consent and re-show banner
377
- widget.downloadReceipt() // trigger JSON consent receipt download
378
653
  widget.destroy() // unmount DOM elements and remove all event listeners
654
+
655
+ // Runtime configuration
656
+ widget.setDarkMode() // toggle dark mode
657
+ widget.setDarkMode(true) // force dark on
658
+ widget.setDarkMode(false) // force light
659
+ widget.setTheme({ primaryColor: '#ff0000' }) // hot-swap CSS tokens (merges into current theme)
660
+ widget.setConfig({ darkMode: true }) // deep-merge partial config (no re-init)
661
+ widget.setProfile({ mainBanner: { heading: 'Updated' } }) // re-render UI with new profile data
662
+
663
+ // Diagnostics
664
+ widget.version() // { package: '1.0.0', profileVersion: 2, consentVersion: 1 }
665
+ ```
666
+
667
+ ### Consent checks
668
+
669
+ ```ts
670
+ // Gate analytics code on a single cookie
671
+ if (widget.isCookieGranted('analytics_storage')) {
672
+ initAnalytics()
673
+ }
674
+
675
+ // Read the raw status string when you need it
676
+ const status = widget.isCookieGranted('marketing', true) // 'granted' | 'denied' | 'objected' | false
677
+
678
+ // Gate a feature on an entire category (all cookies in category must be granted)
679
+ if (widget.isCategoryGranted('cat-analytics')) {
680
+ loadHeatmaps()
681
+ }
682
+
683
+ // Inspect each cookie's status in a category
684
+ const statuses = widget.isCategoryGranted('cat-marketing', true)
685
+ // [{ ad_storage: 'granted' }, { ad_personalization: 'denied' }]
686
+ ```
687
+
688
+ ### Typed event subscriptions
689
+
690
+ ```ts
691
+ // Both forms are accepted — 'consenti:' prefix is optional
692
+ const handler = (data) => console.log('Consent saved:', data.consent)
693
+ widget.on('consentSubmitted', handler)
694
+
695
+ // Remove later (must pass the same function reference)
696
+ widget.off('consentSubmitted', handler)
697
+
698
+ // Available event names:
699
+ // 'bannerInitialized' | 'bannerVisibility' | 'modalVisibility'
700
+ // 'consentBeingSubmitted' | 'consentSubmitted'
379
701
  ```
380
702
 
381
703
  ### Manual init
382
704
 
383
705
  ```ts
384
706
  const widget = new ConsentiSetup({
385
- core: {},
707
+ compliance: { type: 'opt-in' },
386
708
  rootEl: '#consent-mount',
387
709
  autoInit: false,
388
710
  })
@@ -400,32 +722,40 @@ await widget.submitConsent({
400
722
  marketing: 'denied',
401
723
  necessary: 'granted', // mandatory cookies are always 'granted' regardless
402
724
  })
725
+
726
+ // Or use the convenience methods:
727
+ await widget.grantAll() // accept all
728
+ await widget.denyAll() // Reject Optional non-mandatory
403
729
  ```
404
730
 
405
731
  ---
406
732
 
407
733
  ## Events
408
734
 
409
- Custom DOM events fire on `document` at every lifecycle step. All are prefixed `consenti:`.
410
-
411
- | Event | Fired when | Detail type |
412
- |--------------------------------|----------------------------------------------------------|--------------------------------|
413
- | `consenti:bannerInitialized` | Widget initialises and determines banner visibility | `BannerInitializedDetail` |
414
- | `consenti:bannerVisibility` | Banner shows or hides | `BannerVisibilityDetail` |
415
- | `consenti:modalVisibility` | Preference modal shows or hides | `ModalVisibilityDetail` |
416
- | `consenti:consentBeingSubmitted` | User clicked a consent button (before API call) | `ConsentBeingSubmittedDetail` |
417
- | `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) | `ConsentSubmittedDetail` |
418
-
419
- ```ts
420
- document.addEventListener('consenti:consentSubmitted', (e: Event) => {
421
- const detail = (e as CustomEvent<ConsentSubmittedDetail>).detail
422
- // detail.consentAction — 'accept_all' | 'reject_all' | 'custom' | 'update'
423
- // detail.consentJson — { analytics: 'granted', marketing: 'denied', ... }
424
- // detail.visitorId — stable UUID
425
- // detail.pageUrl — window.location.href at submission time
426
- // detail.gpcDetected — boolean
427
- // detail.timestamp — ISO 8601
428
- // detail.apiResponse — backend response (if api.enabled: true)
735
+ Custom DOM events fire on `window` at every lifecycle step. All are prefixed `consenti:`.
736
+
737
+ | Event | Fired when |
738
+ |--------------------------------|----------------------------------------------------------|
739
+ | `consenti:bannerInitialized` | Widget initialises and determines banner visibility |
740
+ | `consenti:bannerVisibility` | Banner shows or hides |
741
+ | `consenti:modalVisibility` | Preference modal shows or hides |
742
+ | `consenti:consentBeingSubmitted` | User clicked a consent button (before API call) |
743
+ | `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) |
744
+ | `consenti:parentalConsentRequired` | Age gate declined with `requireParentalConsent: true` — carries `parentalConsentToken` |
745
+
746
+ **Recommended:** use `widget.on()` / `widget.off()` for typed subscriptions (the `consenti:` prefix is optional):
747
+
748
+ ```ts
749
+ const handler = (data: ConsentEvent) => console.log(data.consent)
750
+ widget.on('consentSubmitted', handler)
751
+ widget.off('consentSubmitted', handler) // same reference to unsubscribe
752
+ ```
753
+
754
+ **Raw DOM listeners** (equivalent):
755
+
756
+ ```ts
757
+ window.addEventListener('consenti:consentSubmitted', (e: Event) => {
758
+ const detail = (e as CustomEvent<ConsentEvent>).detail
429
759
  })
430
760
  ```
431
761
 
@@ -433,39 +763,64 @@ document.addEventListener('consenti:consentSubmitted', (e: Event) => {
433
763
 
434
764
  ## GTM / Google Consent Mode v2
435
765
 
766
+ Setting `utils.gtm` to any object (even `{}`) turns on real Consent Mode signalling — no gtag.js
767
+ needs to already be on the page, Consenti defines the standard stub itself.
768
+
436
769
  ```ts
437
770
  new ConsentiSetup({
438
- core: { regulation: 'gdpr' },
771
+ compliance: { type: 'opt-in' },
439
772
  utils: {
440
773
  gtm: {
441
- containerId: 'GTM-XXXXXX',
442
- urlPassthrough: true, // cookieless conversion modelling
443
- adsDataRedaction: true, // redact ad pings when consent denied
774
+ containerId: 'GTM-XXXXXX', // omit if you load GTM/gtag.js yourself
775
+ urlPassthrough: true, // cookieless conversion modelling
776
+ adsDataRedaction: true, // redact ad pings when consent denied
444
777
  },
445
778
  },
446
779
  })
447
780
  ```
448
781
 
449
- Consenti pushes to `window.dataLayer` in the standard Google Consent Mode v2 format:
782
+ **`GtmConfig` options:**
783
+
784
+ | Option | Type | Default | Description |
785
+ |---|---|---|---|
786
+ | `containerId` | `string` | `undefined` | GTM container ID, e.g. `'GTM-XXXXXX'`. When set, Consenti injects the GTM library itself — omit if you already load GTM/gtag.js separately (Consent Mode signalling works either way). |
787
+ | `dataLayer` | `string` | `'dataLayer'` | Name of the dataLayer array on `window`. Override only for a custom variable name. |
788
+ | `verbose` | `boolean` | `false` | When `true`, additionally mirrors every `consenti:*` event onto the dataLayer as a generic `{ event, content }` push — for custom, non-Consent-Mode GTM triggers. |
789
+ | `events` | `string[]` | `[]` | Only relevant when `verbose: true` — narrows which event names get mirrored. Empty means all. Has no effect on the core `gtag('consent', …)` calls, which always fire. |
790
+ | `urlPassthrough` | `boolean` | `false` | Calls `gtag('set', 'url_passthrough', true)` alongside every consent update. |
791
+ | `adsDataRedaction` | `boolean` | `false` | Calls `gtag('set', 'ads_data_redaction', true)` when `ad_storage` is denied. |
792
+
793
+ Consenti calls the real `gtag()` consent API — via the standard stub-queue pattern, so it works
794
+ whether your own gtag.js/GTM snippet loads before or after Consenti:
450
795
 
451
796
  ```js
452
797
  // On initialisation (default denied state)
453
- dataLayer.push({ 'event': 'consent_default', 'analytics_storage': 'denied', ... })
798
+ gtag('consent', 'default', { analytics_storage: 'denied', ... })
454
799
 
455
800
  // On submission
456
- dataLayer.push({ 'event': 'consent_update', 'analytics_storage': 'granted', 'ad_storage': 'denied', ... })
801
+ gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'denied', ... })
802
+
803
+ // Plus, when configured:
804
+ gtag('set', 'url_passthrough', true)
805
+ gtag('set', 'ads_data_redaction', true) // true when ad_storage is denied
457
806
  ```
458
807
 
459
- `getGTMConsent()` returns consent in GTM format:
808
+ Set `utils.gtm.verbose: true` to additionally mirror every `consenti:*` event onto the dataLayer
809
+ as a generic `{ event, content }` push — off by default.
810
+
811
+ `getConsent('google-gtm')` returns consent in Google Consent Mode v2 format. `getGTMConsent()` is a deprecated alias for the same call.
460
812
 
461
813
  ```ts
462
- widget.getGTMConsent()
814
+ widget.getConsent('google-gtm')
463
815
  // {
464
816
  // analytics_storage: 'granted',
465
817
  // ad_storage: 'denied',
466
818
  // ad_user_data: 'denied',
467
819
  // ad_personalization: 'denied',
468
820
  // functionality_storage: 'granted',
821
+ // security_storage: 'granted',
822
+ // ads_data_redaction: 'true',
823
+ // url_passthrough: 'false',
469
824
  // }
470
825
  ```
471
826
 
@@ -483,6 +838,8 @@ new ConsentScript({
483
838
  src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXX',
484
839
  onLoad: () => console.log('Analytics loaded'),
485
840
  onRevoke: () => console.log('Analytics removed'),
841
+ // bind: true (default) — auto-removes script on consent revoke, re-injects on re-grant
842
+ // bind: false — check consent once at construction; never auto-remove
486
843
  })
487
844
  ```
488
845
 
@@ -555,8 +912,8 @@ Override any token in your own stylesheet:
555
912
 
556
913
  ```ts
557
914
  new ConsentiSetup({
915
+ compliance: { type: 'opt-in' },
558
916
  core: {
559
- regulation: 'gdpr',
560
917
  theme: {
561
918
  primaryColor: '#7c3aed', // purple brand
562
919
  primaryTextColor: '#ffffff',
@@ -572,7 +929,7 @@ new ConsentiSetup({
572
929
 
573
930
  ```ts
574
931
  new ConsentiSetup({
575
- core: { profileId: 1 },
932
+ compliance: { type: 'opt-in' },
576
933
  darkMode: true, // or: window.matchMedia('(prefers-color-scheme: dark)').matches
577
934
  })
578
935
  ```
@@ -631,7 +988,8 @@ import '@consenti/ui/dist/index.css'
631
988
  export function ConsentSetup() {
632
989
  useEffect(() => {
633
990
  const widget = new ConsentiSetup({
634
- core: { regulation: 'gdpr', locale: 'en' },
991
+ compliance: { type: 'opt-in' },
992
+ core: { locale: 'en' },
635
993
  })
636
994
  return () => widget.destroy()
637
995
  }, [])
@@ -687,8 +1045,8 @@ export function ConsentSetup() {
687
1045
  let widget: WidgetType
688
1046
  import('@consenti/ui').then(({ ConsentiSetup }) => {
689
1047
  widget = new ConsentiSetup({
690
- core: { regulation: 'gdpr', locale: 'en', autoHonorGPC: true },
691
1048
  api: { enabled: true, baseUrl: process.env.NEXT_PUBLIC_API_URL },
1049
+ core: { },
692
1050
  })
693
1051
  widgetRef.current = widget
694
1052
  })
@@ -710,7 +1068,7 @@ let widget: unknown = null
710
1068
  onMounted(async () => {
711
1069
  const { ConsentiSetup } = await import('@consenti/ui')
712
1070
  await import('@consenti/ui/dist/index.css')
713
- widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
1071
+ widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
714
1072
  })
715
1073
 
716
1074
  onBeforeUnmount(() => (widget as { destroy?: () => void })?.destroy?.())
@@ -739,7 +1097,7 @@ export class ConsentService implements OnDestroy {
739
1097
  async init() {
740
1098
  if (!isPlatformBrowser(this.platformId)) return
741
1099
  const { ConsentiSetup } = await import('@consenti/ui')
742
- this.widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
1100
+ this.widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
743
1101
  }
744
1102
 
745
1103
  ngOnDestroy() {
@@ -766,7 +1124,7 @@ export class MyComponent {
766
1124
  import { ConsentiSetup } from '@consenti/ui'
767
1125
  import '@consenti/ui/dist/index.css'
768
1126
 
769
- const widget = new ConsentiSetup({ core: { regulation: 'gdpr', locale: 'en' } })
1127
+ const widget = new ConsentiSetup({ compliance: { type: 'opt-in' }, core: { locale: 'en' } })
770
1128
 
771
1129
  document.querySelector('#cookie-settings')?.addEventListener('click', () => {
772
1130
  widget.showModal()
@@ -798,7 +1156,7 @@ class MyPlugin extends ConsentiPlugin {
798
1156
  }
799
1157
 
800
1158
  new ConsentiSetup({
801
- core: { regulation: 'gdpr' },
1159
+ compliance: { type: 'opt-in' },
802
1160
  plugins: [new MyPlugin()],
803
1161
  })
804
1162
  ```
@@ -809,7 +1167,7 @@ new ConsentiSetup({
809
1167
 
810
1168
  ```ts
811
1169
  // Safe to call during SSR — silently no-ops, never touches the DOM:
812
- const widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
1170
+ const widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
813
1171
  // All browser API access is guarded by isClient() internally.
814
1172
  ```
815
1173
 
@@ -830,8 +1188,8 @@ import {
830
1188
  simulateConsentSubmitted,
831
1189
  } from '@consenti/ui/testing'
832
1190
 
833
- mockAllGranted() // fake-grant all cookies for unit tests
834
- mockAllDenied() // fake-deny all
1191
+ mockAllGranted(['analytics', 'marketing']) // fake-grant these cookies for unit tests
1192
+ mockAllDenied(['analytics', 'marketing']) // fake-deny these cookies
835
1193
  simulateConsentSubmitted({ analytics: 'granted' })
836
1194
  ```
837
1195
 
@@ -840,20 +1198,44 @@ simulateConsentSubmitted({ analytics: 'granted' })
840
1198
  ## Minimal Recipes
841
1199
 
842
1200
  ```ts
843
- // CCPA opt-out (no banner shown)
844
- new ConsentiSetup({ core: { regulation: 'ccpa' } })
1201
+ // Auto geo-route — no backend, client-side timezone + language heuristic
1202
+ new ConsentiSetup({})
1203
+
1204
+ // CCPA opt-out for all visitors
1205
+ new ConsentiSetup({ compliance: { type: 'opt-out' } })
845
1206
 
846
1207
  // Cross-subdomain consent
847
- new ConsentiSetup({ core: { regulation: 'gdpr', storage: 'cookie', cookieDomains: '.example.com' } })
1208
+ new ConsentiSetup({ core: { storage: 'cookie', cookieDomains: '.example.com' } })
848
1209
 
849
1210
  // Authenticated user — cross-device sync via API
850
- new ConsentiSetup({ core: { regulation: 'gdpr', userId: '{{ server_user_id }}' }, api: { enabled: true } })
1211
+ new ConsentiSetup({
1212
+ core: { userId: '{{ server_user_id }}' },
1213
+ api: { enabled: true },
1214
+ })
851
1215
 
852
- // GPC strict mode + GTM
1216
+ // GTM
853
1217
  new ConsentiSetup({
854
- core: { regulation: 'gdpr', autoHonorGPC: 'strict' },
1218
+ core: {},
855
1219
  utils: { gtm: { containerId: 'GTM-XXXXXX', adsDataRedaction: true } },
856
1220
  })
1221
+
1222
+ // Verbose debug logging during development
1223
+ new ConsentiSetup({
1224
+ core: { console: ['error', 'warning', 'info', 'log'] },
1225
+ })
1226
+
1227
+ // Arabic site — RTL derived automatically from locale
1228
+ new ConsentiSetup({ core: { locale: 'ar' } })
1229
+
1230
+ // COPPA — block under-13s, require parental consent
1231
+ new ConsentiSetup({
1232
+ compliance: { ageGate: { enabled: true, minimumAge: 13, requireParentalConsent: true } },
1233
+ })
1234
+
1235
+ // TCF — install the __tcfapi stub for ad-tech scripts (cmpId must match the backend's tcf config)
1236
+ new ConsentiSetup({
1237
+ compliance: { tcf: { enabled: true, cmpId: 280, cmpVersion: 1 } },
1238
+ })
857
1239
  ```
858
1240
 
859
1241
  ---
@@ -864,17 +1246,32 @@ All types are exported from the main entry:
864
1246
 
865
1247
  ```ts
866
1248
  import type {
867
- ConsentiConfig, // top-level config object
868
- CoreConfig, // core section
869
- ApiConfig, // api section
870
- UtilsConfig, // utils section
871
- GtmConfig, // utils.gtm section
872
- ThemeConfig, // core.theme section
873
- ConsentValue, // 'granted' | 'denied' | 'objected'
874
- ConsentiProfile, // local profile class
1249
+ ConsentiConfig, // top-level config object
1250
+ CoreConfig, // core section
1251
+ ApiConfig, // api section
1252
+ ComplianceWidgetConfig, // compliance section
1253
+ AgeGateWidgetConfig, // compliance.ageGate section
1254
+ WidgetCountryResolverFn, // custom geo resolver function type
1255
+ UtilsConfig, // utils section
1256
+ GtmConfig, // utils.gtm section
1257
+ ThemeConfig, // core.theme section
1258
+ ConsentValue, // 'granted' | 'denied' | 'objected'
1259
+ ConsentiProfile, // local profile class
1260
+ NonEmptyArray, // [T, ...T[]] — used by usePrebuiltProfiles
875
1261
  } from '@consenti/ui'
876
1262
  ```
877
1263
 
1264
+ ### Breaking changes from v0.0.x
1265
+
1266
+ The following `CoreConfig` fields were **removed** in v0.1.x:
1267
+
1268
+ | Removed field | Replacement |
1269
+ |----------------------|-------------|
1270
+ | `core.profileId` | Use `compliance.type` with a compliance group or local profile key |
1271
+ | `core.regulation` | Use `compliance.type` — compliance group is derived server-side or from pre-built profiles |
1272
+ | `core.signCookies` | Implicit: set `core.cookieSigningKey` to enable signing; no separate flag needed |
1273
+ | `core.privacyPolicyUrl` | Add a link button directly in the profile banner/modal `buttons` map with `action: 'link'` |
1274
+
878
1275
  ---
879
1276
 
880
1277
  ## Contributing