@consenti/ui 0.2.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.
package/README.md CHANGED
@@ -114,8 +114,8 @@ const widget = new ConsentiSetup({
114
114
  core: {
115
115
  tenantId: 'acme', // identifies your tenant's profiles; default: 'default'
116
116
  locale: 'en', // BCP 47; 'auto' = navigator.language; default: 'auto'
117
- autoHonorGPC: true, // explicit operator override: false | true | 'strict'
118
- // overrides profile.gpcMode when set
117
+ dir: 'auto', // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale
118
+ // (ar/he/fa/ur/ps/sd/ug/yi → rtl); default: 'auto'
119
119
  storage: 'cookie', // 'cookie' | 'localStorage'; default: 'cookie'
120
120
  cookieDomains: '.example.com', // comma-separated; first entry used as Domain attribute
121
121
  cookieSigningKey: 'min-32-char-secret', // HMAC-SHA256 signing; implicit from presence
@@ -166,12 +166,19 @@ const widget = new ConsentiSetup({
166
166
  // WidgetCountryResolverFn → custom async function: () => Promise<{ country, region, confidence }>
167
167
  geoDataProvider: 'default',
168
168
 
169
- // Age gate (optional)
169
+ // Age gate (optional) — see "Age Gate" section below
170
170
  ageGate: {
171
171
  enabled: false,
172
172
  minimumAge: 13,
173
173
  requireParentalConsent: false,
174
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
+ },
175
182
  },
176
183
 
177
184
  // ── Mount point (optional) ────────────────────────────────────────────────
@@ -211,6 +218,30 @@ const widget = new ConsentiSetup({
211
218
  })
212
219
  ```
213
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
+
214
245
  ---
215
246
 
216
247
  ## Compliance Groups
@@ -260,7 +291,7 @@ new ConsentiSetup({
260
291
  | **1A** | `api.enabled = false`, `compliance.type = 'auto'` | Timezone + language → group → pre-built profile |
261
292
  | **1B** | `api.enabled = false`, `compliance.type = ComplianceGroupId` | Direct pre-built profile load |
262
293
  | **2A** | `api.enabled = true`, `compliance.type = 'auto'` | `GET /resolve-profile?tz&lang&locale&tenantId` → `filePath` → static JSON |
263
- | **2B** | `api.enabled = true`, `compliance.type = ComplianceGroupId` | `GET /profiles/:tenantId/:group/:locale.json` |
294
+ | **2B** | `api.enabled = true`, `compliance.type = ComplianceGroupId` | `GET /profiles/:tenantId/:group/:locale` |
264
295
  | **Local** | `compliance.type = localKey` + registered `ConsentiProfile` | Locally registered profile |
265
296
  | **Fallback** | Any fetch failure | Pre-built profile → `DEFAULT_PROFILE` |
266
297
 
@@ -276,25 +307,80 @@ Set `api.trustDomain: true` to bypass this check (useful for localhost / dev env
276
307
 
277
308
  ## GPC — Global Privacy Control
278
309
 
279
- GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config (`core.autoHonorGPC`) overrides the profile value when explicitly set.
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):
280
318
 
281
319
  ```ts
282
- // Respect profile's gpcMode setting (default)
283
- new ConsentiSetup({ compliance: { type: 'opt-in' } })
320
+ new ConsentiSetup({
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).
284
336
 
285
- // Force honor GPC regardless of profile setting
286
- new ConsentiSetup({ core: { autoHonorGPC: true } })
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).
287
339
 
288
- // Force strict GPC — pre-deny silently, no banner
289
- new ConsentiSetup({ core: { autoHonorGPC: 'strict' } })
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
352
+ },
353
+ })
290
354
  ```
291
355
 
292
- | `core.autoHonorGPC` | Effect |
293
- |---|---|
294
- | `undefined` (default) | Profile's `gpcMode` takes effect |
295
- | `false` | GPC signal always ignored |
296
- | `true` | `'honor'` — pre-deny GPC cookies; show GPC banner variant |
297
- | `'strict'` | Pre-deny silently — no banner shown |
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.
298
384
 
299
385
  ---
300
386
 
@@ -327,12 +413,12 @@ import { ConsentiProfile, ConsentiSetup } from '@consenti/ui'
327
413
  const profile = new ConsentiProfile({
328
414
  defaultLocale: 'en',
329
415
  complianceGroup: 'opt-in',
330
- cookies: [
331
- { id: 'necessary', mandatory: true, expiry: 365 },
332
- { id: 'analytics', listenGpc: true, expiry: 365 },
333
- { id: 'marketing', listenGpc: true, expiry: 365, cpraCategory: 'sale' },
416
+ cookies: {
417
+ necessary: { purpose: 'necessary', listenGpc: false },
418
+ analytics: { purpose: 'analytics', listenGpc: true },
419
+ marketing: { purpose: 'marketing', listenGpc: true, cpraCategory: 'sale' },
334
420
  // cpraCategory: 'sale' | 'sharing' | 'sensitive' — used by CPRA group
335
- ],
421
+ },
336
422
  translations: {
337
423
  en: {
338
424
  mainBanner: {
@@ -343,13 +429,14 @@ const profile = new ConsentiProfile({
343
429
  heading: 'We value your privacy',
344
430
  headingTag: 'h2', // HTML tag for the heading; default 'h2'
345
431
  htmlText: 'We use cookies to improve your experience.',
346
- buttons: [
347
- { 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
348
434
  // cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
349
- { text: 'Reject Optional', style: 'secondary', action: 'custom', cookies: '!' },
350
- { text: 'Customize', style: 'secondary', action: 'manage' },
351
- { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
352
- ],
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
+ },
353
440
  },
354
441
  gpcBanner: { // shown instead of mainBanner when GPC detected
355
442
  position: 'bottom',
@@ -357,10 +444,10 @@ const profile = new ConsentiProfile({
357
444
  headingTag: 'h2',
358
445
  showLocaleSwitcher: false,
359
446
  htmlText: "Your browser's GPC signal was detected. Ad cookies have been pre-denied.",
360
- buttons: [
361
- { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
362
- { text: 'Customize', style: 'secondary', action: 'manage' },
363
- ],
447
+ buttons: {
448
+ understood: { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
449
+ customize: { text: 'Customize', style: 'secondary', action: 'manage' },
450
+ },
364
451
  },
365
452
  preferenceModal: {
366
453
  heading: 'Cookie Preferences',
@@ -373,34 +460,32 @@ const profile = new ConsentiProfile({
373
460
  persistent: false, // true = cannot dismiss by clicking outside
374
461
  overlayOpacity: 50,
375
462
  mobileFullScreenBreakpoint: 576,
376
- buttons: [
377
- { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
378
- { text: 'Save Preferences', style: 'primary', action: 'submit' },
379
- { text: 'Reject Optional', style: 'text', action: 'custom', cookies: '!' },
380
- ],
381
- categories: [
382
- {
383
- id: 'cat-necessary',
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: {
384
470
  heading: 'Strictly Necessary',
385
471
  headingTag: 'h3',
386
472
  htmlText: 'Required for the site to function. <strong>Cannot be disabled.</strong>',
387
- mandatory: true,
473
+ legalBasis: 'mandatory',
388
474
  cookies: ['necessary'],
389
475
  },
390
- {
391
- id: 'cat-analytics',
476
+ analytics: {
392
477
  heading: 'Analytics',
393
478
  htmlText: 'Helps us understand how visitors use the site.',
394
- type: 'consent', // 'consent' | 'legitimate_interest'
479
+ legalBasis: 'consent', // 'mandatory' | 'consent' | 'legitimate_interest'
395
480
  cookies: ['analytics'],
396
481
  },
397
- {
398
- id: 'cat-marketing',
482
+ marketing: {
399
483
  heading: 'Marketing',
400
484
  htmlText: 'Used to personalise ads and measure campaign performance.',
485
+ legalBasis: 'consent',
401
486
  cookies: ['marketing'],
402
487
  },
403
- ],
488
+ },
404
489
  },
405
490
  },
406
491
  fr: {
@@ -435,18 +520,77 @@ The widget calls `GET /resolve-profile?tz&lang&locale&tenantId`, receives the pr
435
520
 
436
521
  ## Cookie Format
437
522
 
438
- Consent is persisted as a single cookie named `consenti_{userId}_{profileId}`.
523
+ Consent is persisted as a single cookie named `consenti_data` with a compact JSON value:
439
524
 
525
+ ```json
526
+ {"s":1,"v":2,"i":"5huf-…","u":"","t":1751692400,"g":0,"p":0,"c":{"analytics_storage":"g","ad_storage":"d"}}
440
527
  ```
441
- 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
+ })
442
559
  ```
443
560
 
444
- 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 }
445
571
  ```
446
- 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
+ }
447
581
  ```
448
582
 
449
- 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.
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
+ }
593
+ ```
450
594
 
451
595
  ---
452
596
 
@@ -472,7 +616,13 @@ const widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
472
616
  // Consent state
473
617
  widget.hasConsent() // boolean — true if valid consent record exists
474
618
  widget.getConsent() // Record<string, 'granted'|'denied'|'objected'> | null
475
- widget.getGTMConsent() // consent in GTM / Google Consent Mode v2 format
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')
476
626
  widget.getConsentDate() // Date | false — time of last submission
477
627
  widget.isCookieGranted('analytics') // boolean — true if that cookie is 'granted'
478
628
  widget.isCookieGranted('analytics', true)// 'granted' | 'denied' | 'objected' | false
@@ -575,7 +725,7 @@ await widget.submitConsent({
575
725
 
576
726
  // Or use the convenience methods:
577
727
  await widget.grantAll() // accept all
578
- await widget.denyAll() // reject all non-mandatory
728
+ await widget.denyAll() // Reject Optional non-mandatory
579
729
  ```
580
730
 
581
731
  ---
@@ -591,6 +741,7 @@ Custom DOM events fire on `window` at every lifecycle step. All are prefixed `co
591
741
  | `consenti:modalVisibility` | Preference modal shows or hides |
592
742
  | `consenti:consentBeingSubmitted` | User clicked a consent button (before API call) |
593
743
  | `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) |
744
+ | `consenti:parentalConsentRequired` | Age gate declined with `requireParentalConsent: true` — carries `parentalConsentToken` |
594
745
 
595
746
  **Recommended:** use `widget.on()` / `widget.off()` for typed subscriptions (the `consenti:` prefix is optional):
596
747
 
@@ -612,39 +763,64 @@ window.addEventListener('consenti:consentSubmitted', (e: Event) => {
612
763
 
613
764
  ## GTM / Google Consent Mode v2
614
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
+
615
769
  ```ts
616
770
  new ConsentiSetup({
617
771
  compliance: { type: 'opt-in' },
618
772
  utils: {
619
773
  gtm: {
620
- containerId: 'GTM-XXXXXX',
621
- urlPassthrough: true, // cookieless conversion modelling
622
- 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
623
777
  },
624
778
  },
625
779
  })
626
780
  ```
627
781
 
628
- 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:
629
795
 
630
796
  ```js
631
797
  // On initialisation (default denied state)
632
- dataLayer.push({ 'event': 'consent_default', 'analytics_storage': 'denied', ... })
798
+ gtag('consent', 'default', { analytics_storage: 'denied', ... })
633
799
 
634
800
  // On submission
635
- 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
636
806
  ```
637
807
 
638
- `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.
639
812
 
640
813
  ```ts
641
- widget.getGTMConsent()
814
+ widget.getConsent('google-gtm')
642
815
  // {
643
816
  // analytics_storage: 'granted',
644
817
  // ad_storage: 'denied',
645
818
  // ad_user_data: 'denied',
646
819
  // ad_personalization: 'denied',
647
820
  // functionality_storage: 'granted',
821
+ // security_storage: 'granted',
822
+ // ads_data_redaction: 'true',
823
+ // url_passthrough: 'false',
648
824
  // }
649
825
  ```
650
826
 
@@ -870,7 +1046,7 @@ export function ConsentSetup() {
870
1046
  import('@consenti/ui').then(({ ConsentiSetup }) => {
871
1047
  widget = new ConsentiSetup({
872
1048
  api: { enabled: true, baseUrl: process.env.NEXT_PUBLIC_API_URL },
873
- core: { autoHonorGPC: true },
1049
+ core: { },
874
1050
  })
875
1051
  widgetRef.current = widget
876
1052
  })
@@ -1037,9 +1213,9 @@ new ConsentiSetup({
1037
1213
  api: { enabled: true },
1038
1214
  })
1039
1215
 
1040
- // GPC strict mode + GTM
1216
+ // GTM
1041
1217
  new ConsentiSetup({
1042
- core: { autoHonorGPC: 'strict' },
1218
+ core: {},
1043
1219
  utils: { gtm: { containerId: 'GTM-XXXXXX', adsDataRedaction: true } },
1044
1220
  })
1045
1221
 
@@ -1047,6 +1223,19 @@ new ConsentiSetup({
1047
1223
  new ConsentiSetup({
1048
1224
  core: { console: ['error', 'warning', 'info', 'log'] },
1049
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
+ })
1050
1239
  ```
1051
1240
 
1052
1241
  ---
@@ -1081,7 +1270,7 @@ The following `CoreConfig` fields were **removed** in v0.1.x:
1081
1270
  | `core.profileId` | Use `compliance.type` with a compliance group or local profile key |
1082
1271
  | `core.regulation` | Use `compliance.type` — compliance group is derived server-side or from pre-built profiles |
1083
1272
  | `core.signCookies` | Implicit: set `core.cookieSigningKey` to enable signing; no separate flag needed |
1084
- | `core.privacyPolicyUrl` | Add a link button directly in the profile banner/modal `buttons` array with `action: 'link'` |
1273
+ | `core.privacyPolicyUrl` | Add a link button directly in the profile banner/modal `buttons` map with `action: 'link'` |
1085
1274
 
1086
1275
  ---
1087
1276