@consenti/ui 0.2.0 → 0.4.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 +512 -138
- package/dist/angular.d.cts +610 -151
- package/dist/angular.d.ts +610 -151
- package/dist/angular.js +1 -1
- package/dist/angular.js.map +1 -1
- package/dist/angular.mjs +1 -1
- package/dist/angular.mjs.map +1 -1
- package/dist/chunk-3EXCD24H.mjs +2 -0
- package/dist/{chunk-K2UN3O67.mjs.map → chunk-3EXCD24H.mjs.map} +1 -1
- package/dist/chunk-RDE7CM6T.mjs +2 -0
- package/dist/chunk-RDE7CM6T.mjs.map +1 -0
- package/dist/http-AQVVUCX6.mjs +2 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +971 -125
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/profiles-5ZLWWN2L.mjs +2 -0
- package/dist/profiles-5ZLWWN2L.mjs.map +1 -0
- package/dist/react.d.cts +589 -129
- package/dist/react.d.ts +589 -129
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/testing.mjs.map +1 -1
- package/dist/vue.d.cts +562 -79
- package/dist/vue.d.ts +562 -79
- package/dist/vue.js +1 -1
- package/dist/vue.js.map +1 -1
- package/dist/vue.mjs +1 -1
- package/dist/vue.mjs.map +1 -1
- package/package.json +13 -8
- package/dist/chunk-K2UN3O67.mjs +0 -2
- package/dist/http-UU2NIW6M.mjs +0 -2
- /package/dist/{http-UU2NIW6M.mjs.map → http-AQVVUCX6.mjs.map} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @consenti/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
GDPR-style cookie consent widget for any web framework — zero required runtime dependencies.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@consenti/ui)
|
|
6
6
|
[](../../LICENSE)
|
|
@@ -12,12 +12,15 @@ Zero-dependency, GDPR-compliant cookie consent widget for any web framework.
|
|
|
12
12
|
|
|
13
13
|
- **Zero runtime dependencies** — browser built-ins only (`fetch`, `crypto.subtle`, `BroadcastChannel`, `CustomEvent`, `document.cookie`, `localStorage`)
|
|
14
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
|
|
15
|
+
- **Multi-regulation** — Maintained: GDPR, CCPA, CPRA, LGPD. Partial: TCF v2.3 (real binary encoding requires the `@consenti/api` backend) — only relevant if you monetize through programmatic/RTB ad exchanges. In development: DPDPA. More via 8 built-in compliance groups — see [ECOSYSTEM.md](../../ECOSYSTEM.md) for per-regulation status
|
|
16
|
+
|
|
17
|
+
> **Registration is only needed for programmatic ad monetization.** For first-party analytics/marketing consent, Consenti is fully compliant with zero external registration. Consenti implements the IAB TCF/GPP technical specs but is **not** itself a registered CMP; if you do need TCF/GPP, you must register your own `cmpId` with IAB Europe/MSPA independently before going live — see the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration).
|
|
16
18
|
- **Automatic geo-resolution** — detects visitor jurisdiction client-side (timezone + language) or server-side (`/resolve-profile`)
|
|
17
19
|
- **GPC aware** — Global Privacy Control signal detection with `'honor'` / `'strict'` / `'ignore'` modes per profile
|
|
18
20
|
- **GTM / Google Consent Mode v2** — built-in `dataLayer` integration
|
|
19
21
|
- **SSR-safe** — all browser API access is guarded; `new ConsentiSetup()` during SSR is a silent no-op
|
|
20
22
|
- **Fully typed** — ships TypeScript definitions; no `@types` needed
|
|
23
|
+
- **Companion CLI**: [`@consenti/scanner`](../scanner) crawls a site under none/reject-all/accept-all consent states and reports undeclared third-party trackers
|
|
21
24
|
|
|
22
25
|
---
|
|
23
26
|
|
|
@@ -35,17 +38,15 @@ ES2020+ · Chrome 80+ · Firefox 74+ · Safari 13.1+
|
|
|
35
38
|
npm install @consenti/ui
|
|
36
39
|
```
|
|
37
40
|
|
|
38
|
-
Then import
|
|
41
|
+
Then import the class:
|
|
39
42
|
|
|
40
43
|
```ts
|
|
41
44
|
import { ConsentiSetup } from '@consenti/ui'
|
|
42
|
-
import '@consenti/ui/dist/index.css'
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
### CDN / UMD (no build step)
|
|
46
48
|
|
|
47
49
|
```html
|
|
48
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.css" />
|
|
49
50
|
<script src="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.umd.js"></script>
|
|
50
51
|
<script>
|
|
51
52
|
const { ConsentiSetup } = ConsentiUI
|
|
@@ -63,12 +64,15 @@ import '@consenti/ui/dist/index.css'
|
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
### CSS options
|
|
67
|
+
Choose any approach to load stylesheet, by default it will be auto injected
|
|
66
68
|
|
|
67
69
|
| Approach | How |
|
|
68
70
|
|-----------------------------------|-----------------------------------------------------------------------|
|
|
69
|
-
|
|
|
71
|
+
| Auto Injection (recommended) | by default consenti will inject css |
|
|
72
|
+
| Manual injection (avoids FOUC) | `import '@consenti/ui/dist/index.css'` in your bundler entry or code and set `disableCssTemplate: true;` |
|
|
70
73
|
| Skip all CSS (bring your own) | Set `core.disableCssTemplate: true` — no styles injected |
|
|
71
74
|
| CSS custom properties only | Import CSS, then override `--consenti-*` variables in your stylesheet |
|
|
75
|
+
| core.theme config in ConsentiSetup| Provide css variables for your stylesheet in the setup as js object |
|
|
72
76
|
|
|
73
77
|
---
|
|
74
78
|
|
|
@@ -107,21 +111,30 @@ A consent banner appears on first visit. Everything below is optional.
|
|
|
107
111
|
|
|
108
112
|
```ts
|
|
109
113
|
import { ConsentiSetup } from '@consenti/ui'
|
|
110
|
-
import '@consenti/ui/dist/index.css'
|
|
111
114
|
|
|
112
115
|
const widget = new ConsentiSetup({
|
|
113
116
|
// ── Core (optional) ──────────────────────────────────────────────────────────
|
|
114
117
|
core: {
|
|
115
118
|
tenantId: 'acme', // identifies your tenant's profiles; default: 'default'
|
|
116
119
|
locale: 'en', // BCP 47; 'auto' = navigator.language; default: 'auto'
|
|
117
|
-
|
|
118
|
-
//
|
|
120
|
+
dir: 'auto', // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale via
|
|
121
|
+
// Intl.Locale(...).getTextInfo().direction (falls back to a
|
|
122
|
+
// hand-maintained ar/he/fa/ur/ps/sd/ug/yi → rtl list on
|
|
123
|
+
// engines without getTextInfo() support); default: 'auto'
|
|
119
124
|
storage: 'cookie', // 'cookie' | 'localStorage'; default: 'cookie'
|
|
125
|
+
cookieName: 'consenti_data', // cookie/localStorage key name; default: 'consenti_data'
|
|
126
|
+
// not switched automatically by detected region — set
|
|
127
|
+
// explicitly if you want a different name (e.g. 'euconsent-v2',
|
|
128
|
+
// only meaningful if you're using the spec-correct binary TCF
|
|
129
|
+
// encoder — @consenti/api + @iabtechlabtcf/core — since that
|
|
130
|
+
// name implies IAB's binary format, not Consenti's own encoding)
|
|
120
131
|
cookieDomains: '.example.com', // comma-separated; first entry used as Domain attribute
|
|
121
132
|
cookieSigningKey: 'min-32-char-secret', // HMAC-SHA256 signing; implicit from presence
|
|
122
133
|
allowReceipt: true, // allow consent receipt download; default: false
|
|
123
134
|
disableCssTemplate: false, // skip all style injection; default: false
|
|
124
135
|
userId: 'server-assigned-uuid', // authenticated users — enables cross-device sync
|
|
136
|
+
// prefer widget.getUserId()/setUserId() (or the
|
|
137
|
+
// consenti:listener:identify event) to change it after init
|
|
125
138
|
|
|
126
139
|
// Pre-built profile fallback
|
|
127
140
|
usePrebuiltProfiles: 'all', // 'all' (default) | ['opt-in', 'opt-out', ...] (non-empty)
|
|
@@ -131,24 +144,31 @@ const widget = new ConsentiSetup({
|
|
|
131
144
|
console: ['error'], // log levels emitted: 'info' | 'log' | 'warning' | 'error'
|
|
132
145
|
// default: ['error'] only — no noise in production
|
|
133
146
|
|
|
147
|
+
// Field names mirror their --consenti-* CSS variable literally (--consenti-color-primary
|
|
148
|
+
// → colorPrimary). Every field is optional and maps 1:1 to one CSS custom property — see
|
|
149
|
+
// apps/ui/src/styles/_variables.scss for the full set of ~33 variables. Common ones:
|
|
134
150
|
theme: {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
colorBg: '#ffffff',
|
|
152
|
+
colorText: '#1a1a1a',
|
|
153
|
+
colorTextMuted: '#949dab',
|
|
154
|
+
colorPrimary: '#1565c0',
|
|
155
|
+
colorPrimaryText: '#ffffff',
|
|
156
|
+
colorSecondary: '#f0f4f8',
|
|
157
|
+
colorSecondaryText: '#1a3460',
|
|
158
|
+
colorBorder: '#e2e8f0',
|
|
159
|
+
colorAccent: '#d32f2f',
|
|
160
|
+
colorAccentText: '#ffffff',
|
|
144
161
|
fontFamily: 'system-ui, sans-serif',
|
|
145
162
|
fontSizeBase: '14px',
|
|
146
163
|
fontSizeHeading: '18px',
|
|
147
164
|
fontSizeMultiplier: '1',
|
|
148
165
|
borderRadius: '8px',
|
|
149
|
-
|
|
166
|
+
borderRadiusBtn: '4px',
|
|
150
167
|
toggleBgOn: '#1565c0',
|
|
151
168
|
toggleBgOff: '#cccccc',
|
|
169
|
+
// ...plus colorSecondaryBorder, colorOverlay, fontFamilyMono, fontWeightHeading,
|
|
170
|
+
// lineHeight, spacingXs/Sm/Md/Lg, shadow, toggleBgPartial, toggleKnob,
|
|
171
|
+
// toggleWidth/Height, zBanner/Overlay/Modal
|
|
152
172
|
},
|
|
153
173
|
},
|
|
154
174
|
|
|
@@ -166,11 +186,28 @@ const widget = new ConsentiSetup({
|
|
|
166
186
|
// WidgetCountryResolverFn → custom async function: () => Promise<{ country, region, confidence }>
|
|
167
187
|
geoDataProvider: 'default',
|
|
168
188
|
|
|
169
|
-
//
|
|
170
|
-
|
|
189
|
+
// Only meaningful when api.enabled = false (ignored, with a warning, otherwise — the server
|
|
190
|
+
// resolves the group in that mode). Only overrides country→group mapping; country/region
|
|
191
|
+
// detection above always uses the embedded geo data.
|
|
192
|
+
// 'default' → embedded map (default) | a URL string (fetched; the browser's own HTTP cache
|
|
193
|
+
// honors whatever Cache-Control/ETag the response sends) | an inline ComplianceMapData object
|
|
194
|
+
complianceMap: 'default',
|
|
195
|
+
|
|
196
|
+
// TCF (IAB Transparency & Consent Framework) client stub (optional) — see "TCF" section below
|
|
197
|
+
tcf: {
|
|
198
|
+
enabled: false,
|
|
199
|
+
cmpId: 0, // must match the cmpId configured on the backend (TcfConfig)
|
|
200
|
+
cmpVersion: 1,
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// GPP (IAB Global Privacy Platform, US National section) client stub (optional) — see "GPP" section below
|
|
204
|
+
gpp: {
|
|
171
205
|
enabled: false,
|
|
172
|
-
|
|
173
|
-
|
|
206
|
+
cmpId: 0, // must match the cmpId configured on the backend (GppConfig)
|
|
207
|
+
cmpVersion: 1,
|
|
208
|
+
mspaCoveredTransaction: false, // no honest default — required
|
|
209
|
+
mspaOptOutOptionMode: 0, // 0 = not applicable | 1 = yes | 2 = no
|
|
210
|
+
mspaServiceProviderMode: 0,
|
|
174
211
|
},
|
|
175
212
|
},
|
|
176
213
|
|
|
@@ -211,6 +248,30 @@ const widget = new ConsentiSetup({
|
|
|
211
248
|
})
|
|
212
249
|
```
|
|
213
250
|
|
|
251
|
+
### `profileOverride` — deep-merge and delete semantics
|
|
252
|
+
|
|
253
|
+
`profileOverride` (and `widget.setProfile()` at runtime) is deep-merged onto the resolved profile
|
|
254
|
+
— server-fetched, pre-built, or locally registered — before anything renders. Merge rules:
|
|
255
|
+
|
|
256
|
+
- Omitting a key (or setting it to `undefined`) leaves the resolved profile's value untouched.
|
|
257
|
+
- An object value merges recursively, key by key — you only need to specify what differs.
|
|
258
|
+
- An array value replaces/merges by index against the base array.
|
|
259
|
+
- Setting a key to `null` **deletes it** from the merged result (JSON Merge Patch / RFC 7396
|
|
260
|
+
semantics). This is the way to remove a single entry from a keyed map — a cookie category, a
|
|
261
|
+
parameter — without having to know or repeat the rest of that map's contents:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
profileOverride: {
|
|
265
|
+
preferenceModal: {
|
|
266
|
+
categories: { marketing: null }, // removes the 'marketing' category entirely
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`{ marketing: undefined }` or `{ marketing: {} }` do **not** delete the entry — the former is a
|
|
272
|
+
no-op (same as omitting the key), the latter merges an empty object onto the existing category,
|
|
273
|
+
changing nothing. Only an explicit `null` removes it.
|
|
274
|
+
|
|
214
275
|
---
|
|
215
276
|
|
|
216
277
|
## Compliance Groups
|
|
@@ -260,7 +321,7 @@ new ConsentiSetup({
|
|
|
260
321
|
| **1A** | `api.enabled = false`, `compliance.type = 'auto'` | Timezone + language → group → pre-built profile |
|
|
261
322
|
| **1B** | `api.enabled = false`, `compliance.type = ComplianceGroupId` | Direct pre-built profile load |
|
|
262
323
|
| **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
|
|
324
|
+
| **2B** | `api.enabled = true`, `compliance.type = ComplianceGroupId` | `GET /profiles/:tenantId/:group/:locale` |
|
|
264
325
|
| **Local** | `compliance.type = localKey` + registered `ConsentiProfile` | Locally registered profile |
|
|
265
326
|
| **Fallback** | Any fetch failure | Pre-built profile → `DEFAULT_PROFILE` |
|
|
266
327
|
|
|
@@ -276,25 +337,158 @@ Set `api.trustDomain: true` to bypass this check (useful for localhost / dev env
|
|
|
276
337
|
|
|
277
338
|
## GPC — Global Privacy Control
|
|
278
339
|
|
|
279
|
-
GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config
|
|
340
|
+
GPC mode is set per-profile on the server (`gpcMode: 'honor' | 'strict' | 'ignore'`). Widget config profileOverrides overrides the profile value when explicitly set.
|
|
341
|
+
|
|
342
|
+
`gpcMode` only applies once the widget itself has loaded and initialized. If a tag-loading script
|
|
343
|
+
sits earlier in `<head>` (GTM/gtag.js, or another script), it can still fire before that. Use
|
|
344
|
+
`buildSyncGpcSnippet()` to close that gap — it returns a tiny, dependency-free `<script>` string to
|
|
345
|
+
place first in `<head>`, ahead of everything else, that checks `navigator.globalPrivacyControl`
|
|
346
|
+
synchronously and pre-freezes Google Consent Mode v2 to denied:
|
|
280
347
|
|
|
281
348
|
```ts
|
|
282
|
-
|
|
283
|
-
|
|
349
|
+
import { buildSyncGpcSnippet } from '@consenti/ui'
|
|
350
|
+
|
|
351
|
+
buildSyncGpcSnippet() // default dataLayer name
|
|
352
|
+
buildSyncGpcSnippet({ dataLayerName: 'myDataLayer' }) // custom dataLayer name
|
|
353
|
+
```
|
|
284
354
|
|
|
285
|
-
|
|
286
|
-
|
|
355
|
+
Safe to run before Consenti's own `gtm` config pushes its defaults on init — same values, same
|
|
356
|
+
stub-queue, so the second push is a no-op rather than a conflict. Full walkthrough with the
|
|
357
|
+
`<head>` ordering: [Advanced Configuration → GPC](https://consenti.dev/docs/ui/advanced-configuration).
|
|
287
358
|
|
|
288
|
-
|
|
289
|
-
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Age Gate
|
|
362
|
+
|
|
363
|
+
Age gate is a **per-profile, per-locale** setting — not a widget-config option. GDPR Article 8's
|
|
364
|
+
consent age varies 13–16 by EU member state and DPDPA has its own child-data age rules, so one
|
|
365
|
+
global age doesn't fit a multi-region deployment. Enable it on a profile in the dashboard's Profile
|
|
366
|
+
Editor Step 1 (minimum age, optional parental-consent requirement), and author the modal's
|
|
367
|
+
heading/body/button text per locale in the Main Banner content step — the widget reads it off the
|
|
368
|
+
resolved profile (`profile.ageGate`/`profile.ageGateModal`) automatically, no widget config needed.
|
|
369
|
+
For a standalone/local profile (`registerProfile()`), set `ageGate`/`ageGateModal` directly on the
|
|
370
|
+
`EmbeddedProfile`/`EmbeddedTranslations` you register.
|
|
371
|
+
|
|
372
|
+
When enabled, it blocks every other consent UI (banner, GPC, CCPA opt-out — all of it waits behind
|
|
373
|
+
the age gate on first visit):
|
|
374
|
+
|
|
375
|
+
- **Confirmed** (visitor is old enough) → the normal banner/GPC/CCPA flow proceeds exactly as if
|
|
376
|
+
the age gate didn't exist, and every consent submission from then on carries `ageVerified: true`.
|
|
377
|
+
- **Declined, `requireParentalConsent: false`** → a deny-all consent is submitted immediately
|
|
378
|
+
(mandatory/strictly-necessary cookies still granted), no banner shown, `ageVerified: false`.
|
|
379
|
+
- **Declined, `requireParentalConsent: true`** → same deny-all submission, plus a
|
|
380
|
+
`parentalConsentToken` is requested from the backend (`POST /consent/:visitorId/parental-consent-request`,
|
|
381
|
+
signed with the backend's `compliance.dataSigningHash` when configured — falls back to a
|
|
382
|
+
client-generated, unsigned token if `api.enabled` is false) and a
|
|
383
|
+
`consenti:parentalConsentRequired` event fires carrying it. There's no email-sending pipeline
|
|
384
|
+
built into this package — the event (or your backend's `eventBus.on('consent.parentalConsentRequired', ...)`
|
|
385
|
+
listener, see the `@consenti/api` README's "Parental consent" section) is the hook for wiring
|
|
386
|
+
your own out-of-band verification process.
|
|
387
|
+
|
|
388
|
+
When the parent later completes that process (often on a fresh page with no live widget instance —
|
|
389
|
+
their own device, not the child's), resolve the token with the standalone `resolveParentalConsent`
|
|
390
|
+
export instead of a `ConsentiSetup` method:
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
import { resolveParentalConsent } from '@consenti/ui'
|
|
394
|
+
|
|
395
|
+
const { visitorId, profileId } = await resolveParentalConsent(token, { baseUrl: 'https://consent.example.com' })
|
|
396
|
+
// dispatches consenti:parentalConsentResolved on window — a page with a live widget instance
|
|
397
|
+
// can listen for it and react (e.g. re-show the banner for the real consent choice); what
|
|
398
|
+
// "resolved" should actually do to consent state is host-defined, same as the request side.
|
|
290
399
|
```
|
|
291
400
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
401
|
+
This is plumbing, not verifiable parental consent by itself — see the `@consenti/api` README for
|
|
402
|
+
the full caveats (token replay isn't prevented, staying stateless is a deliberate tradeoff).
|
|
403
|
+
|
|
404
|
+
The prompt only asks once per visitor per profile — it doesn't re-appear once any consent record
|
|
405
|
+
exists (same "already decided" check the banner itself uses).
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## TCF — IAB Transparency & Consent Framework
|
|
410
|
+
|
|
411
|
+
Set `compliance.tcf.enabled: true` to install `window.__tcfapi`, the standard IAB entry point
|
|
412
|
+
third-party ad-tech scripts call to read consent (`ping`, `getTCData`, `addEventListener`,
|
|
413
|
+
`removeEventListener`):
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
new ConsentiSetup({
|
|
417
|
+
compliance: {
|
|
418
|
+
tcf: { enabled: true, cmpId: 280, cmpVersion: 1 }, // cmpId must match the backend's TcfConfig
|
|
419
|
+
},
|
|
420
|
+
})
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
The stub follows the standard queue-command convention, so it's safe regardless of load order
|
|
424
|
+
relative to third-party scripts that call `__tcfapi` before Consenti has initialized. Only one CMP
|
|
425
|
+
may own `window.__tcfapi` per page — if it's already set (a real CMP, or another `ConsentiSetup`
|
|
426
|
+
instance on a multi-profile page), this stub does not overwrite it.
|
|
427
|
+
|
|
428
|
+
This is a simplified implementation: `tcString` is a base64url-encoded JSON payload (the same
|
|
429
|
+
default format the backend's `tcfString` uses), not the full IAB binary bitfield encoding, and
|
|
430
|
+
vendor-list metadata (`gvlVersion`, `publisherCC`) is operator-supplied via `TcfWidgetConfig`
|
|
431
|
+
rather than fetched client-side (the GVL is multi-megabyte; IAB policy expects CMPs to cache their
|
|
432
|
+
own copy server-side rather than have every visitor's browser re-fetch it). `gdprApplies` is
|
|
433
|
+
derived automatically from the resolved profile's compliance group.
|
|
434
|
+
|
|
435
|
+
For spec-correct binary TC-string encoding, run with the `@consenti/api` backend, set
|
|
436
|
+
`compliance.tcf.publisherCC`, and install the optional `@iabtechlabtcf/core` peer dependency
|
|
437
|
+
(the actively-maintained IAB Tech Lab package — not `iabtcf-core`, which doesn't exist on npm).
|
|
438
|
+
See the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration) for details.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## GPP — IAB Global Privacy Platform (US National)
|
|
443
|
+
|
|
444
|
+
Set `compliance.gpp.enabled: true` to install `window.__gpp`, the standard IAB entry point
|
|
445
|
+
scripts call for US state-privacy-law signals (`ping`, `addEventListener`, `removeEventListener`,
|
|
446
|
+
`getSection`, `hasSection`) — `usnat` (US National) section only:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
new ConsentiSetup({
|
|
450
|
+
compliance: {
|
|
451
|
+
gpp: {
|
|
452
|
+
enabled: true,
|
|
453
|
+
cmpId: 280, // must match the backend's GppConfig
|
|
454
|
+
cmpVersion: 1,
|
|
455
|
+
mspaCoveredTransaction: true, // whether this deployment falls under MSPA signatory obligations
|
|
456
|
+
mspaOptOutOptionMode: 1, // 0 = not applicable | 1 = yes | 2 = no
|
|
457
|
+
mspaServiceProviderMode: 0,
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Sale/sharing opt-out flags are derived automatically from the resolved profile's cookies tagged
|
|
464
|
+
`cpraCategory: 'sale'` / `'sharing'` and the visitor's actual consent — no separate config needed.
|
|
465
|
+
Like TCF, only one CMP may own `window.__gpp` per page; this stub does not overwrite an existing one,
|
|
466
|
+
and follows the standard stub-queue convention so load order relative to third-party scripts doesn't
|
|
467
|
+
matter.
|
|
468
|
+
|
|
469
|
+
Unlike TCF, GPP's US National section needs no Global Vendor List, so both this stub's `gppString`
|
|
470
|
+
and the backend's `gppString` use the same real, spec-correct encoder when the optional
|
|
471
|
+
`@iabgpp/cmpapi` peer dependency is installed on `@consenti/api` — there's no "simplified fallback"
|
|
472
|
+
format the way TCF has. Without it, `gppString` falls back to a base64url-encoded JSON payload.
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## RTL / Text Direction
|
|
477
|
+
|
|
478
|
+
`core.dir` controls the banner/modal's reading direction:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
new ConsentiSetup({ core: { locale: 'ar', dir: 'auto' } }) // → rtl, derived from locale
|
|
482
|
+
new ConsentiSetup({ core: { dir: 'rtl' } }) // explicit, independent of locale
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
`'auto'` (the default) maps a known RTL-language prefix (`ar`, `he`, `fa`, `ur`, `ps`, `sd`, `ug`,
|
|
486
|
+
`yi`) to `rtl`; anything else is `ltr`. The `dir` attribute is set on the widget's root element, so
|
|
487
|
+
browser-native mirroring (flex layout, `text-align: start/end`) and the widget's own `[dir="rtl"]`
|
|
488
|
+
CSS overrides (toggle switches, locale switcher, close button) apply automatically — no separate
|
|
489
|
+
RTL stylesheet needed. Explicit banner/modal position variants (`left-bottom`, `right-bottom`,
|
|
490
|
+
modal `left`/`right` slide-in) are **not** mirrored — those are screen positions you chose
|
|
491
|
+
deliberately, independent of text direction.
|
|
298
492
|
|
|
299
493
|
---
|
|
300
494
|
|
|
@@ -327,12 +521,12 @@ import { ConsentiProfile, ConsentiSetup } from '@consenti/ui'
|
|
|
327
521
|
const profile = new ConsentiProfile({
|
|
328
522
|
defaultLocale: 'en',
|
|
329
523
|
complianceGroup: 'opt-in',
|
|
330
|
-
cookies:
|
|
331
|
-
{
|
|
332
|
-
{
|
|
333
|
-
{
|
|
524
|
+
cookies: {
|
|
525
|
+
necessary: { purpose: 'necessary', listenGpc: false },
|
|
526
|
+
analytics: { purpose: 'analytics', listenGpc: true },
|
|
527
|
+
marketing: { purpose: 'marketing', listenGpc: true, cpraCategory: 'sale' },
|
|
334
528
|
// cpraCategory: 'sale' | 'sharing' | 'sensitive' — used by CPRA group
|
|
335
|
-
|
|
529
|
+
},
|
|
336
530
|
translations: {
|
|
337
531
|
en: {
|
|
338
532
|
mainBanner: {
|
|
@@ -343,13 +537,14 @@ const profile = new ConsentiProfile({
|
|
|
343
537
|
heading: 'We value your privacy',
|
|
344
538
|
headingTag: 'h2', // HTML tag for the heading; default 'h2'
|
|
345
539
|
htmlText: 'We use cookies to improve your experience.',
|
|
346
|
-
buttons:
|
|
347
|
-
|
|
540
|
+
buttons: {
|
|
541
|
+
// key = machine id, rendered as the button's DOM id: `consenti-btn-{id}`, for targeting a specific button
|
|
348
542
|
// cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
|
|
349
|
-
{ text: '
|
|
350
|
-
{ text: '
|
|
351
|
-
{ text: '
|
|
352
|
-
|
|
543
|
+
'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
|
|
544
|
+
'reject-optional': { text: 'Reject Optional', style: 'primary', action: 'custom', cookies: '!' }, // same weight as accept-all — CNIL requires reject not be visually subordinate
|
|
545
|
+
customize: { text: 'Customize', style: 'secondary', action: 'manage' },
|
|
546
|
+
'privacy-policy': { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
|
|
547
|
+
},
|
|
353
548
|
},
|
|
354
549
|
gpcBanner: { // shown instead of mainBanner when GPC detected
|
|
355
550
|
position: 'bottom',
|
|
@@ -357,10 +552,10 @@ const profile = new ConsentiProfile({
|
|
|
357
552
|
headingTag: 'h2',
|
|
358
553
|
showLocaleSwitcher: false,
|
|
359
554
|
htmlText: "Your browser's GPC signal was detected. Ad cookies have been pre-denied.",
|
|
360
|
-
buttons:
|
|
361
|
-
{ text: 'Understood', style: 'primary',
|
|
362
|
-
{ text: 'Customize',
|
|
363
|
-
|
|
555
|
+
buttons: {
|
|
556
|
+
understood: { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
|
|
557
|
+
customize: { text: 'Customize', style: 'secondary', action: 'manage' },
|
|
558
|
+
},
|
|
364
559
|
},
|
|
365
560
|
preferenceModal: {
|
|
366
561
|
heading: 'Cookie Preferences',
|
|
@@ -373,34 +568,32 @@ const profile = new ConsentiProfile({
|
|
|
373
568
|
persistent: false, // true = cannot dismiss by clicking outside
|
|
374
569
|
overlayOpacity: 50,
|
|
375
570
|
mobileFullScreenBreakpoint: 576,
|
|
376
|
-
buttons:
|
|
377
|
-
{ text: 'Accept All',
|
|
378
|
-
{ text: 'Save Preferences', style: 'primary', action: 'submit' },
|
|
379
|
-
{ text: 'Reject Optional',
|
|
380
|
-
|
|
381
|
-
categories:
|
|
382
|
-
{
|
|
383
|
-
id: 'cat-necessary',
|
|
571
|
+
buttons: {
|
|
572
|
+
'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
|
|
573
|
+
'save-preferences': { text: 'Save Preferences', style: 'primary', action: 'submit' },
|
|
574
|
+
'reject-optional': { text: 'Reject Optional', style: 'text', action: 'custom', cookies: '!' },
|
|
575
|
+
},
|
|
576
|
+
categories: {
|
|
577
|
+
necessary: {
|
|
384
578
|
heading: 'Strictly Necessary',
|
|
385
579
|
headingTag: 'h3',
|
|
386
580
|
htmlText: 'Required for the site to function. <strong>Cannot be disabled.</strong>',
|
|
387
|
-
|
|
581
|
+
legalBasis: 'mandatory',
|
|
388
582
|
cookies: ['necessary'],
|
|
389
583
|
},
|
|
390
|
-
{
|
|
391
|
-
id: 'cat-analytics',
|
|
584
|
+
analytics: {
|
|
392
585
|
heading: 'Analytics',
|
|
393
586
|
htmlText: 'Helps us understand how visitors use the site.',
|
|
394
|
-
|
|
587
|
+
legalBasis: 'consent', // 'mandatory' | 'consent' | 'legitimate_interest'
|
|
395
588
|
cookies: ['analytics'],
|
|
396
589
|
},
|
|
397
|
-
{
|
|
398
|
-
id: 'cat-marketing',
|
|
590
|
+
marketing: {
|
|
399
591
|
heading: 'Marketing',
|
|
400
592
|
htmlText: 'Used to personalise ads and measure campaign performance.',
|
|
593
|
+
legalBasis: 'consent',
|
|
401
594
|
cookies: ['marketing'],
|
|
402
595
|
},
|
|
403
|
-
|
|
596
|
+
},
|
|
404
597
|
},
|
|
405
598
|
},
|
|
406
599
|
fr: {
|
|
@@ -435,18 +628,77 @@ The widget calls `GET /resolve-profile?tz&lang&locale&tenantId`, receives the pr
|
|
|
435
628
|
|
|
436
629
|
## Cookie Format
|
|
437
630
|
|
|
438
|
-
Consent is persisted as a single cookie named `
|
|
631
|
+
Consent is persisted as a single cookie named `consenti_data` with a compact JSON value:
|
|
439
632
|
|
|
633
|
+
```json
|
|
634
|
+
{"s":1,"v":2,"i":"5huf-…","u":"","t":1751692400,"g":0,"p":0,"c":{"analytics_storage":"g","ad_storage":"d"}}
|
|
440
635
|
```
|
|
441
|
-
|
|
636
|
+
|
|
637
|
+
| Field | Type | Meaning |
|
|
638
|
+
|---|---|---|
|
|
639
|
+
| `s` | number | Profile ID |
|
|
640
|
+
| `v` | number | Profile version |
|
|
641
|
+
| `i` | string | Per-submission UUID (consent receipt ID) |
|
|
642
|
+
| `u` | string | Logged-in user ID (`""` for anonymous) |
|
|
643
|
+
| `t` | number | Unix timestamp of consent |
|
|
644
|
+
| `g` | 0 \| 1 | GPC signal detected |
|
|
645
|
+
| `p` | number | Consent source: 0 = user click, 1 = widget method |
|
|
646
|
+
| `c` | object | Consent map: cookie ID → `"g"` (granted), `"o"` (objected), `"d"` (denied) |
|
|
647
|
+
|
|
648
|
+
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.
|
|
649
|
+
|
|
650
|
+
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.
|
|
651
|
+
|
|
652
|
+
---
|
|
653
|
+
|
|
654
|
+
## Display Features
|
|
655
|
+
|
|
656
|
+
### Footer Metadata
|
|
657
|
+
|
|
658
|
+
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.
|
|
659
|
+
|
|
660
|
+
Configured in the dashboard (ProfileEditor → Step 1) or via `profileOverride`:
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
new ConsentiSetup({
|
|
664
|
+
compliance: { type: 'opt-in' },
|
|
665
|
+
profileOverride: { showFooterMetadata: true },
|
|
666
|
+
})
|
|
442
667
|
```
|
|
443
668
|
|
|
444
|
-
|
|
669
|
+
### Enhanced Accessibility
|
|
670
|
+
|
|
671
|
+
When `enhanceAccessibility: true` is set in the resolved profile, the class `.consenti--enhanced-a11y` is added to the root element. This applies:
|
|
672
|
+
|
|
673
|
+
- Minimum 44 × 44 px hit area on all buttons (WCAG 2.1 SC 2.5.5)
|
|
674
|
+
- 3 px solid focus ring (`outline-offset: 2px`) on all focusable elements
|
|
675
|
+
- No impact on default styling — opt-in only
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
profileOverride: { enhanceAccessibility: true }
|
|
445
679
|
```
|
|
446
|
-
|
|
680
|
+
|
|
681
|
+
### Responsive Button Stacking (`stackButtonsOnBreakpoint`)
|
|
682
|
+
|
|
683
|
+
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.
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
profileOverride: {
|
|
687
|
+
mainBanner: { stackButtonsOnBreakpoint: 576 }, // px; 0 or undefined = disabled
|
|
688
|
+
}
|
|
447
689
|
```
|
|
448
690
|
|
|
449
|
-
|
|
691
|
+
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()`.
|
|
692
|
+
|
|
693
|
+
### Modal Focus Trap (`trapFocus`)
|
|
694
|
+
|
|
695
|
+
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.
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
profileOverride: {
|
|
699
|
+
preferenceModal: { trapFocus: true },
|
|
700
|
+
}
|
|
701
|
+
```
|
|
450
702
|
|
|
451
703
|
---
|
|
452
704
|
|
|
@@ -472,7 +724,13 @@ const widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
|
|
|
472
724
|
// Consent state
|
|
473
725
|
widget.hasConsent() // boolean — true if valid consent record exists
|
|
474
726
|
widget.getConsent() // Record<string, 'granted'|'denied'|'objected'> | null
|
|
475
|
-
widget.
|
|
727
|
+
widget.getConsent('google-gtm') // Google Consent Mode v2 format
|
|
728
|
+
widget.getConsent('category') // { necessary, functional, preferences, analytics, marketing }
|
|
729
|
+
widget.getConsent('adobe') // { analytics, target, manager, optimizer }
|
|
730
|
+
widget.getConsent('meta') // { pixel, api, plugins, facebookLogin }
|
|
731
|
+
widget.getConsent('microsoft-clarity') // { session, heatmaps, performance }
|
|
732
|
+
widget.getConsent('twilio-segment') // { identify, page, track, group, alias }
|
|
733
|
+
widget.getGTMConsent() // @deprecated — use getConsent('google-gtm')
|
|
476
734
|
widget.getConsentDate() // Date | false — time of last submission
|
|
477
735
|
widget.isCookieGranted('analytics') // boolean — true if that cookie is 'granted'
|
|
478
736
|
widget.isCookieGranted('analytics', true)// 'granted' | 'denied' | 'objected' | false
|
|
@@ -502,11 +760,19 @@ widget.deleteConsent() // delete consent record (cookie + backend if e
|
|
|
502
760
|
widget.reConsent() // delete consent and re-show banner
|
|
503
761
|
widget.destroy() // unmount DOM elements and remove all event listeners
|
|
504
762
|
|
|
763
|
+
// Logged-in user identity
|
|
764
|
+
widget.getUserId() // string | null — current application user ID
|
|
765
|
+
widget.setUserId('user-42') // reconsents (by default) if it differs from the stored cookie's user
|
|
766
|
+
widget.setUserId(null) // clear identity (e.g. logout)
|
|
767
|
+
widget.setUserId('user-42', false) // update identity without reconsenting
|
|
768
|
+
// Or dispatch from anywhere (e.g. right after your own login flow completes):
|
|
769
|
+
// window.dispatchEvent(new CustomEvent('consenti:listener:identify', { detail: { userId: 'user-42' } }))
|
|
770
|
+
|
|
505
771
|
// Runtime configuration
|
|
506
772
|
widget.setDarkMode() // toggle dark mode
|
|
507
773
|
widget.setDarkMode(true) // force dark on
|
|
508
774
|
widget.setDarkMode(false) // force light
|
|
509
|
-
widget.setTheme({
|
|
775
|
+
widget.setTheme({ colorPrimary: '#ff0000' }) // hot-swap CSS tokens (merges into current theme)
|
|
510
776
|
widget.setConfig({ darkMode: true }) // deep-merge partial config (no re-init)
|
|
511
777
|
widget.setProfile({ mainBanner: { heading: 'Updated' } }) // re-render UI with new profile data
|
|
512
778
|
|
|
@@ -575,7 +841,7 @@ await widget.submitConsent({
|
|
|
575
841
|
|
|
576
842
|
// Or use the convenience methods:
|
|
577
843
|
await widget.grantAll() // accept all
|
|
578
|
-
await widget.denyAll() //
|
|
844
|
+
await widget.denyAll() // Reject Optional non-mandatory
|
|
579
845
|
```
|
|
580
846
|
|
|
581
847
|
---
|
|
@@ -591,6 +857,15 @@ Custom DOM events fire on `window` at every lifecycle step. All are prefixed `co
|
|
|
591
857
|
| `consenti:modalVisibility` | Preference modal shows or hides |
|
|
592
858
|
| `consenti:consentBeingSubmitted` | User clicked a consent button (before API call) |
|
|
593
859
|
| `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) |
|
|
860
|
+
| `consenti:parentalConsentRequired` | Age gate declined with `requireParentalConsent: true` — carries `parentalConsentToken` |
|
|
861
|
+
|
|
862
|
+
The events above are **outbound** (widget → host). `consenti:listener:*` is the opposite
|
|
863
|
+
direction — **inbound** (host → widget), dispatched by your own code to tell the widget
|
|
864
|
+
something:
|
|
865
|
+
|
|
866
|
+
| Event | Dispatch when |
|
|
867
|
+
|--------------------------------|----------------------------------------------------------|
|
|
868
|
+
| `consenti:listener:identify` | Your own login/logout flow completes — `detail: { userId: string \| null, reConsent?: boolean }`, equivalent to calling `widget.setUserId(userId, reConsent)` |
|
|
594
869
|
|
|
595
870
|
**Recommended:** use `widget.on()` / `widget.off()` for typed subscriptions (the `consenti:` prefix is optional):
|
|
596
871
|
|
|
@@ -612,39 +887,64 @@ window.addEventListener('consenti:consentSubmitted', (e: Event) => {
|
|
|
612
887
|
|
|
613
888
|
## GTM / Google Consent Mode v2
|
|
614
889
|
|
|
890
|
+
Setting `utils.gtm` to any object (even `{}`) turns on real Consent Mode signalling — no gtag.js
|
|
891
|
+
needs to already be on the page, Consenti defines the standard stub itself.
|
|
892
|
+
|
|
615
893
|
```ts
|
|
616
894
|
new ConsentiSetup({
|
|
617
895
|
compliance: { type: 'opt-in' },
|
|
618
896
|
utils: {
|
|
619
897
|
gtm: {
|
|
620
|
-
containerId: 'GTM-XXXXXX',
|
|
621
|
-
urlPassthrough: true,
|
|
622
|
-
adsDataRedaction: true,
|
|
898
|
+
containerId: 'GTM-XXXXXX', // omit if you load GTM/gtag.js yourself
|
|
899
|
+
urlPassthrough: true, // cookieless conversion modelling
|
|
900
|
+
adsDataRedaction: true, // redact ad pings when consent denied
|
|
623
901
|
},
|
|
624
902
|
},
|
|
625
903
|
})
|
|
626
904
|
```
|
|
627
905
|
|
|
628
|
-
|
|
906
|
+
**`GtmConfig` options:**
|
|
907
|
+
|
|
908
|
+
| Option | Type | Default | Description |
|
|
909
|
+
|---|---|---|---|
|
|
910
|
+
| `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). |
|
|
911
|
+
| `dataLayer` | `string` | `'dataLayer'` | Name of the dataLayer array on `window`. Override only for a custom variable name. |
|
|
912
|
+
| `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. |
|
|
913
|
+
| `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. |
|
|
914
|
+
| `urlPassthrough` | `boolean` | `false` | Calls `gtag('set', 'url_passthrough', true)` alongside every consent update. |
|
|
915
|
+
| `adsDataRedaction` | `boolean` | `false` | Calls `gtag('set', 'ads_data_redaction', true)` when `ad_storage` is denied. |
|
|
916
|
+
|
|
917
|
+
Consenti calls the real `gtag()` consent API — via the standard stub-queue pattern, so it works
|
|
918
|
+
whether your own gtag.js/GTM snippet loads before or after Consenti:
|
|
629
919
|
|
|
630
920
|
```js
|
|
631
921
|
// On initialisation (default denied state)
|
|
632
|
-
|
|
922
|
+
gtag('consent', 'default', { analytics_storage: 'denied', ... })
|
|
633
923
|
|
|
634
924
|
// On submission
|
|
635
|
-
|
|
925
|
+
gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'denied', ... })
|
|
926
|
+
|
|
927
|
+
// Plus, when configured:
|
|
928
|
+
gtag('set', 'url_passthrough', true)
|
|
929
|
+
gtag('set', 'ads_data_redaction', true) // true when ad_storage is denied
|
|
636
930
|
```
|
|
637
931
|
|
|
638
|
-
`
|
|
932
|
+
Set `utils.gtm.verbose: true` to additionally mirror every `consenti:*` event onto the dataLayer
|
|
933
|
+
as a generic `{ event, content }` push — off by default.
|
|
934
|
+
|
|
935
|
+
`getConsent('google-gtm')` returns consent in Google Consent Mode v2 format. `getGTMConsent()` is a deprecated alias for the same call.
|
|
639
936
|
|
|
640
937
|
```ts
|
|
641
|
-
widget.
|
|
938
|
+
widget.getConsent('google-gtm')
|
|
642
939
|
// {
|
|
643
940
|
// analytics_storage: 'granted',
|
|
644
941
|
// ad_storage: 'denied',
|
|
645
942
|
// ad_user_data: 'denied',
|
|
646
943
|
// ad_personalization: 'denied',
|
|
647
944
|
// functionality_storage: 'granted',
|
|
945
|
+
// security_storage: 'granted',
|
|
946
|
+
// ads_data_redaction: 'true',
|
|
947
|
+
// url_passthrough: 'false',
|
|
648
948
|
// }
|
|
649
949
|
```
|
|
650
950
|
|
|
@@ -667,20 +967,64 @@ new ConsentScript({
|
|
|
667
967
|
})
|
|
668
968
|
```
|
|
669
969
|
|
|
670
|
-
###
|
|
970
|
+
### ConsentAction — run a callback on a parameter's grant status
|
|
971
|
+
|
|
972
|
+
For integrations that expose their own `sdk.optIn()`/`sdk.optOut()` API (Segment, Mixpanel,
|
|
973
|
+
Amplitude, Sentry, etc.) instead of a `<script>` tag — use `ConsentScript` for that case instead.
|
|
974
|
+
|
|
975
|
+
```ts
|
|
976
|
+
import { ConsentAction } from '@consenti/ui'
|
|
977
|
+
|
|
978
|
+
new ConsentAction({
|
|
979
|
+
id: 'analytics_storage',
|
|
980
|
+
widget,
|
|
981
|
+
onGrant: () => analyticsSdk.optIn(),
|
|
982
|
+
onDeny: () => analyticsSdk.optOut(),
|
|
983
|
+
// bind: true (default) — re-fires on every future consent change
|
|
984
|
+
})
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
### CategoryScript / CategoryAction — gate on a whole category
|
|
988
|
+
|
|
989
|
+
Same as `ConsentScript`/`ConsentAction`, but keyed on a preference-modal category instead of a
|
|
990
|
+
single parameter — "granted" only when *every* parameter in the category is `'granted'`.
|
|
671
991
|
|
|
672
992
|
```ts
|
|
673
|
-
import {
|
|
993
|
+
import { CategoryScript, CategoryAction } from '@consenti/ui'
|
|
994
|
+
|
|
995
|
+
new CategoryScript({ categoryId: 'marketing', widget, src: 'https://example.com/ad-pixel.js' })
|
|
996
|
+
|
|
997
|
+
new CategoryAction({
|
|
998
|
+
id: 'marketing',
|
|
999
|
+
widget,
|
|
1000
|
+
onGrant: () => adSdk.enableAll(),
|
|
1001
|
+
onDeny: () => adSdk.disableAll(),
|
|
1002
|
+
})
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### scanConsentScripts — declarative `data-*` script gating
|
|
1006
|
+
|
|
1007
|
+
Zero-JS integration path: mark `<script>` tags with `data-consenti-consent-script` /
|
|
1008
|
+
`data-consenti-category-script` and Consenti scans and wires them up automatically at the end of
|
|
1009
|
+
every `init()` cycle (call `scanConsentScripts(widget)` again manually after adding tags at runtime).
|
|
1010
|
+
|
|
1011
|
+
```html
|
|
1012
|
+
<script type="text/plain" data-consenti-consent-script="analytics_storage" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
|
|
1013
|
+
<script type="text/plain" data-consenti-category-script="marketing" src="https://example.com/pixel.js"></script>
|
|
1014
|
+
<script type="text/plain" data-consenti-consent-script="ad_storage" data-consenti-bind="false">/* inline snippet, evaluated once */</script>
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
### BannerTrigger — open banner or modal from any element
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
import { BannerTrigger } from '@consenti/ui'
|
|
674
1021
|
|
|
675
1022
|
// Attach to existing element
|
|
676
|
-
new
|
|
1023
|
+
new BannerTrigger({ widget, el: '#cookie-settings', action: 'modal' })
|
|
677
1024
|
|
|
678
1025
|
// Auto-create a button
|
|
679
|
-
new
|
|
680
|
-
|
|
681
|
-
label: 'Manage Cookies',
|
|
682
|
-
appendTo: document.querySelector('#footer'),
|
|
683
|
-
})
|
|
1026
|
+
const trigger = new BannerTrigger({ widget, action: 'modal', label: 'Manage Cookies' })
|
|
1027
|
+
document.querySelector('#footer')?.appendChild(trigger.getElement())
|
|
684
1028
|
```
|
|
685
1029
|
|
|
686
1030
|
---
|
|
@@ -694,41 +1038,50 @@ Override any token in your own stylesheet:
|
|
|
694
1038
|
```css
|
|
695
1039
|
:root {
|
|
696
1040
|
/* Colors */
|
|
697
|
-
--consenti-
|
|
698
|
-
--consenti-
|
|
699
|
-
--consenti-
|
|
700
|
-
--consenti-
|
|
701
|
-
--consenti-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
--consenti-
|
|
705
|
-
--consenti-
|
|
706
|
-
--consenti-
|
|
707
|
-
--consenti-
|
|
708
|
-
--consenti-
|
|
709
|
-
--consenti-btn-padding: 0.5rem 1.25rem;
|
|
710
|
-
--consenti-btn-font-weight: 600;
|
|
711
|
-
|
|
712
|
-
/* Banner */
|
|
713
|
-
--consenti-banner-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
|
714
|
-
--consenti-banner-radius: 0;
|
|
715
|
-
--consenti-banner-padding: 1.5rem;
|
|
716
|
-
--consenti-banner-max-width: 960px;
|
|
717
|
-
|
|
718
|
-
/* Modal */
|
|
719
|
-
--consenti-modal-radius: 12px;
|
|
720
|
-
--consenti-modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
|
|
721
|
-
--consenti-modal-width: 560px;
|
|
722
|
-
--consenti-modal-max-height: 80vh;
|
|
723
|
-
|
|
724
|
-
/* Toggle */
|
|
725
|
-
--consenti-toggle-on: #1565c0;
|
|
726
|
-
--consenti-toggle-off: #ccc;
|
|
1041
|
+
--consenti-color-bg: #ffffff;
|
|
1042
|
+
--consenti-color-text: #1a2e4a;
|
|
1043
|
+
--consenti-color-text-muted: #949dab;
|
|
1044
|
+
--consenti-color-primary: #04111f;
|
|
1045
|
+
--consenti-color-primary-text: #ffffff;
|
|
1046
|
+
--consenti-color-secondary: #f0f4f8;
|
|
1047
|
+
--consenti-color-secondary-text: #1a2e4a;
|
|
1048
|
+
--consenti-color-border: #dbe4ee;
|
|
1049
|
+
--consenti-color-secondary-border: #1a2e4a;
|
|
1050
|
+
--consenti-color-overlay: #04111f;
|
|
1051
|
+
--consenti-color-accent: #d32f2f;
|
|
1052
|
+
--consenti-color-accent-text: #ffffff;
|
|
727
1053
|
|
|
728
1054
|
/* Typography */
|
|
729
|
-
--consenti-font-size-base: 14px;
|
|
730
|
-
--consenti-font-size-mult: 1;
|
|
731
1055
|
--consenti-font-family: system-ui, -apple-system, sans-serif;
|
|
1056
|
+
--consenti-font-family-mono: ui-monospace, monospace;
|
|
1057
|
+
--consenti-font-size-base: 14px;
|
|
1058
|
+
--consenti-font-size-heading: 16px;
|
|
1059
|
+
--consenti-font-weight-heading: 600;
|
|
1060
|
+
--consenti-line-height: 1.5;
|
|
1061
|
+
|
|
1062
|
+
/* Spacing */
|
|
1063
|
+
--consenti-spacing-xs: 5px;
|
|
1064
|
+
--consenti-spacing-sm: 8px;
|
|
1065
|
+
--consenti-spacing-md: 16px;
|
|
1066
|
+
--consenti-spacing-lg: 24px;
|
|
1067
|
+
|
|
1068
|
+
/* Shape */
|
|
1069
|
+
--consenti-border-radius: 8px;
|
|
1070
|
+
--consenti-border-radius-btn: 0;
|
|
1071
|
+
--consenti-shadow: 0 4px 24px rgba(21, 101, 192, 0.14);
|
|
1072
|
+
|
|
1073
|
+
/* Toggle (preference modal) */
|
|
1074
|
+
--consenti-toggle-bg-on: #43a047;
|
|
1075
|
+
--consenti-toggle-bg-partial: #97c098;
|
|
1076
|
+
--consenti-toggle-bg-off: #9ca3af;
|
|
1077
|
+
--consenti-toggle-knob: #ffffff;
|
|
1078
|
+
--consenti-toggle-width: 52px;
|
|
1079
|
+
--consenti-toggle-height: 28px;
|
|
1080
|
+
|
|
1081
|
+
/* Stacking */
|
|
1082
|
+
--consenti-z-banner: 9999;
|
|
1083
|
+
--consenti-z-overlay: 9998;
|
|
1084
|
+
--consenti-z-modal: 10000;
|
|
732
1085
|
}
|
|
733
1086
|
```
|
|
734
1087
|
|
|
@@ -739,10 +1092,10 @@ new ConsentiSetup({
|
|
|
739
1092
|
compliance: { type: 'opt-in' },
|
|
740
1093
|
core: {
|
|
741
1094
|
theme: {
|
|
742
|
-
|
|
743
|
-
|
|
1095
|
+
colorPrimary: '#7c3aed', // purple brand
|
|
1096
|
+
colorPrimaryText: '#ffffff',
|
|
744
1097
|
borderRadius: '12px',
|
|
745
|
-
|
|
1098
|
+
borderRadiusBtn: '999px', // pill buttons
|
|
746
1099
|
fontFamily: 'Inter, sans-serif',
|
|
747
1100
|
},
|
|
748
1101
|
},
|
|
@@ -807,7 +1160,6 @@ new ConsentiSetup({
|
|
|
807
1160
|
|
|
808
1161
|
import { useEffect } from 'react'
|
|
809
1162
|
import { ConsentiSetup } from '@consenti/ui'
|
|
810
|
-
import '@consenti/ui/dist/index.css'
|
|
811
1163
|
|
|
812
1164
|
export function ConsentSetup() {
|
|
813
1165
|
useEffect(() => {
|
|
@@ -860,7 +1212,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
860
1212
|
|
|
861
1213
|
import { useEffect, useRef } from 'react'
|
|
862
1214
|
import type { ConsentiSetup as WidgetType } from '@consenti/ui'
|
|
863
|
-
import '@consenti/ui/dist/index.css'
|
|
864
1215
|
|
|
865
1216
|
export function ConsentSetup() {
|
|
866
1217
|
const widgetRef = useRef<WidgetType | null>(null)
|
|
@@ -870,7 +1221,7 @@ export function ConsentSetup() {
|
|
|
870
1221
|
import('@consenti/ui').then(({ ConsentiSetup }) => {
|
|
871
1222
|
widget = new ConsentiSetup({
|
|
872
1223
|
api: { enabled: true, baseUrl: process.env.NEXT_PUBLIC_API_URL },
|
|
873
|
-
core: {
|
|
1224
|
+
core: { },
|
|
874
1225
|
})
|
|
875
1226
|
widgetRef.current = widget
|
|
876
1227
|
})
|
|
@@ -891,7 +1242,6 @@ let widget: unknown = null
|
|
|
891
1242
|
|
|
892
1243
|
onMounted(async () => {
|
|
893
1244
|
const { ConsentiSetup } = await import('@consenti/ui')
|
|
894
|
-
await import('@consenti/ui/dist/index.css')
|
|
895
1245
|
widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
|
|
896
1246
|
})
|
|
897
1247
|
|
|
@@ -930,15 +1280,18 @@ export class ConsentService implements OnDestroy {
|
|
|
930
1280
|
}
|
|
931
1281
|
```
|
|
932
1282
|
|
|
933
|
-
Or use the built-in Angular
|
|
1283
|
+
Or use the built-in Angular integration (`setConsentiWidget()` once at app root, then
|
|
1284
|
+
`injectConsent()` in any component/service):
|
|
934
1285
|
|
|
935
1286
|
```ts
|
|
936
|
-
import {
|
|
1287
|
+
import { injectConsent } from '@consenti/ui/angular'
|
|
937
1288
|
|
|
938
1289
|
@Component({ /* ... */ })
|
|
939
1290
|
export class MyComponent {
|
|
940
|
-
consent =
|
|
1291
|
+
consent = injectConsent()
|
|
941
1292
|
// consent.hasConsent() | consent.showModal() | consent.getConsent()
|
|
1293
|
+
// consent.onConsentChange(cb) / onBannerChange(cb) / onModalChange(cb) — call in
|
|
1294
|
+
// ngOnInit, unsubscribe with the returned function in ngOnDestroy
|
|
942
1295
|
}
|
|
943
1296
|
```
|
|
944
1297
|
|
|
@@ -946,7 +1299,6 @@ export class MyComponent {
|
|
|
946
1299
|
|
|
947
1300
|
```js
|
|
948
1301
|
import { ConsentiSetup } from '@consenti/ui'
|
|
949
|
-
import '@consenti/ui/dist/index.css'
|
|
950
1302
|
|
|
951
1303
|
const widget = new ConsentiSetup({ compliance: { type: 'opt-in' }, core: { locale: 'en' } })
|
|
952
1304
|
|
|
@@ -1037,9 +1389,9 @@ new ConsentiSetup({
|
|
|
1037
1389
|
api: { enabled: true },
|
|
1038
1390
|
})
|
|
1039
1391
|
|
|
1040
|
-
//
|
|
1392
|
+
// GTM
|
|
1041
1393
|
new ConsentiSetup({
|
|
1042
|
-
core: {
|
|
1394
|
+
core: {},
|
|
1043
1395
|
utils: { gtm: { containerId: 'GTM-XXXXXX', adsDataRedaction: true } },
|
|
1044
1396
|
})
|
|
1045
1397
|
|
|
@@ -1047,6 +1399,17 @@ new ConsentiSetup({
|
|
|
1047
1399
|
new ConsentiSetup({
|
|
1048
1400
|
core: { console: ['error', 'warning', 'info', 'log'] },
|
|
1049
1401
|
})
|
|
1402
|
+
|
|
1403
|
+
// Arabic site — RTL derived automatically from locale
|
|
1404
|
+
new ConsentiSetup({ core: { locale: 'ar' } })
|
|
1405
|
+
|
|
1406
|
+
// COPPA — block under-13s, require parental consent: set on the profile in the dashboard
|
|
1407
|
+
// (Profile Editor Step 1 → Enable age gate → minimum age 13, require parental consent), not here.
|
|
1408
|
+
|
|
1409
|
+
// TCF — install the __tcfapi stub for ad-tech scripts (cmpId must match the backend's tcf config)
|
|
1410
|
+
new ConsentiSetup({
|
|
1411
|
+
compliance: { tcf: { enabled: true, cmpId: 280, cmpVersion: 1 } },
|
|
1412
|
+
})
|
|
1050
1413
|
```
|
|
1051
1414
|
|
|
1052
1415
|
---
|
|
@@ -1061,11 +1424,11 @@ import type {
|
|
|
1061
1424
|
CoreConfig, // core section
|
|
1062
1425
|
ApiConfig, // api section
|
|
1063
1426
|
ComplianceWidgetConfig, // compliance section
|
|
1064
|
-
AgeGateWidgetConfig, // compliance.ageGate section
|
|
1065
1427
|
WidgetCountryResolverFn, // custom geo resolver function type
|
|
1066
1428
|
UtilsConfig, // utils section
|
|
1067
1429
|
GtmConfig, // utils.gtm section
|
|
1068
1430
|
ThemeConfig, // core.theme section
|
|
1431
|
+
IdentifyEventDetail, // consenti:listener:identify event detail
|
|
1069
1432
|
ConsentValue, // 'granted' | 'denied' | 'objected'
|
|
1070
1433
|
ConsentiProfile, // local profile class
|
|
1071
1434
|
NonEmptyArray, // [T, ...T[]] — used by usePrebuiltProfiles
|
|
@@ -1081,7 +1444,18 @@ The following `CoreConfig` fields were **removed** in v0.1.x:
|
|
|
1081
1444
|
| `core.profileId` | Use `compliance.type` with a compliance group or local profile key |
|
|
1082
1445
|
| `core.regulation` | Use `compliance.type` — compliance group is derived server-side or from pre-built profiles |
|
|
1083
1446
|
| `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`
|
|
1447
|
+
| `core.privacyPolicyUrl` | Add a link button directly in the profile banner/modal `buttons` map with `action: 'link'` |
|
|
1448
|
+
|
|
1449
|
+
---
|
|
1450
|
+
|
|
1451
|
+
## Security notes
|
|
1452
|
+
|
|
1453
|
+
**`core.cookieSigningKey` is spoofable in standalone mode.** Without a `@consenti/api` backend,
|
|
1454
|
+
this key has nowhere to live but the shipped browser bundle — so anyone can read it out of your JS
|
|
1455
|
+
and re-sign a forged cookie with it. It still catches accidental tampering (e.g. a stale value from
|
|
1456
|
+
an older config), but it is not tamper-*proof* against a motivated attacker. If you need consent
|
|
1457
|
+
records that hold up as evidence, sign them server-side instead with `compliance.dataSigningHash`
|
|
1458
|
+
(`@consenti/api`), where the key never reaches the browser.
|
|
1085
1459
|
|
|
1086
1460
|
---
|
|
1087
1461
|
|