@c15t/nextjs 2.0.0-rc.4 → 2.0.0-rc.6
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/dist/headless.cjs +1 -1
- package/dist/iab/styles.css +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/libs/browser-initial-data.cjs +1 -0
- package/dist/libs/browser-initial-data.js +1 -0
- package/dist/libs/initial-data.cjs +1 -1
- package/dist/libs/initial-data.js +1 -1
- package/dist/styles.css +1 -0
- package/dist/types.cjs +1 -1
- package/dist/version.cjs +1 -1
- package/dist/version.js +1 -1
- package/dist-types/headless.d.ts +1 -0
- package/{dist → dist-types}/index.d.ts +4 -3
- package/dist-types/libs/browser-initial-data.d.ts +9 -0
- package/{dist → dist-types}/libs/initial-data.d.ts +1 -2
- package/dist-types/types.d.ts +38 -0
- package/dist-types/version.d.ts +1 -0
- package/docs/README.md +73 -0
- package/docs/building-headless-components.md +250 -0
- package/docs/callbacks.md +117 -0
- package/docs/components/consent-banner.md +174 -0
- package/docs/components/consent-dialog-link.md +59 -0
- package/docs/components/consent-dialog-trigger.md +103 -0
- package/docs/components/consent-dialog.md +137 -0
- package/docs/components/consent-manager-provider.md +423 -0
- package/docs/components/consent-widget.md +78 -0
- package/docs/components/dev-tools.md +63 -0
- package/docs/components/frame.md +73 -0
- package/docs/concepts/client-modes.md +163 -0
- package/docs/concepts/consent-categories.md +97 -0
- package/docs/concepts/consent-models.md +116 -0
- package/docs/concepts/cookie-management.md +122 -0
- package/docs/concepts/glossary.md +23 -0
- package/docs/concepts/initialization-flow.md +141 -0
- package/docs/concepts/policy-packs.md +229 -0
- package/docs/headless.md +184 -0
- package/docs/hooks/use-color-scheme.md +40 -0
- package/docs/hooks/use-consent-manager/checking-consent.md +94 -0
- package/docs/hooks/use-consent-manager/location-info.md +95 -0
- package/docs/hooks/use-consent-manager/overview.md +390 -0
- package/docs/hooks/use-consent-manager/setting-consent.md +92 -0
- package/docs/hooks/use-draggable.md +57 -0
- package/docs/hooks/use-focus-trap.md +41 -0
- package/docs/hooks/use-reduced-motion.md +35 -0
- package/docs/hooks/use-ssr-status.md +31 -0
- package/docs/hooks/use-text-direction.md +49 -0
- package/docs/hooks/use-translations.md +117 -0
- package/docs/iab/consent-banner.md +95 -0
- package/docs/iab/consent-dialog.md +135 -0
- package/docs/iab/overview.md +119 -0
- package/docs/iab/use-gvl-data.md +208 -0
- package/docs/iframe-blocking.md +107 -0
- package/docs/integrations/databuddy.md +186 -0
- package/docs/integrations/google-tag-manager.md +153 -0
- package/docs/integrations/google-tag.md +149 -0
- package/docs/integrations/linkedin-insights.md +109 -0
- package/docs/integrations/meta-pixel.md +342 -0
- package/docs/integrations/microsoft-uet.md +112 -0
- package/docs/integrations/overview.md +89 -0
- package/docs/integrations/posthog.md +177 -0
- package/docs/integrations/tiktok-pixel.md +113 -0
- package/docs/integrations/x-pixel.md +143 -0
- package/docs/internationalization.md +197 -0
- package/docs/network-blocker.md +178 -0
- package/docs/optimization.md +156 -0
- package/docs/policy-packs.md +246 -0
- package/docs/quickstart.md +155 -0
- package/docs/script-loader.md +300 -0
- package/docs/server-side.md +171 -0
- package/docs/styling/classnames.md +84 -0
- package/docs/styling/color-scheme.md +82 -0
- package/docs/styling/css-variables.md +92 -0
- package/docs/styling/overview.md +312 -0
- package/docs/styling/slots.md +93 -0
- package/docs/styling/tailwind.md +115 -0
- package/docs/styling/tokens.md +214 -0
- package/docs/troubleshooting.md +146 -0
- package/package.json +20 -13
- package/dist/headless.d.ts +0 -2
- package/dist/headless.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/libs/initial-data.d.ts.map +0 -1
- package/dist/types.d.ts +0 -16
- package/dist/types.d.ts.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Policy Packs
|
|
3
|
+
description: How c15t resolves regional consent policies and what a policy pack controls.
|
|
4
|
+
---
|
|
5
|
+
Different countries need different consent experiences. Policy packs let you define those rules once — c15t picks the right one automatically based on where the visitor is.
|
|
6
|
+
|
|
7
|
+
A policy pack is an ordered array of policies. Each policy targets a region or country and controls the consent model, which categories are in scope, what UI is shown, and how consent is recorded.
|
|
8
|
+
|
|
9
|
+
There are three ways to configure policy packs:
|
|
10
|
+
|
|
11
|
+
1. **consent.io (recommended)** — use [consent.io](https://consent.io) as your hosted backend. Configure packs visually in the dashboard or via API — no code changes required. Works with any frontend, including static sites.
|
|
12
|
+
2. **Self-hosted backend** — define packs in code via `policyPacks` and resolve them from real request geo data. Full control over policy logic and storage.
|
|
13
|
+
3. **Offline fallback** — pass the same policy shapes to the frontend via `offlinePolicy.policyPacks`. Used as a resilience fallback when the backend is unreachable, or for quick local experimentation and demos. If you omit `offlinePolicy.policyPacks`, c15t falls back to a synthetic worldwide opt-in banner instead of no-banner mode.
|
|
14
|
+
|
|
15
|
+
In both hosted and self-hosted modes, the **backend is always the source of truth**. Offline packs never override a live backend decision.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
The fastest way to get started is with the built-in presets:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { policyPackPresets } from 'c15t';
|
|
23
|
+
|
|
24
|
+
const policies = [
|
|
25
|
+
policyPackPresets.europeOptIn(), // GDPR opt-in banner
|
|
26
|
+
policyPackPresets.californiaOptOut(), // CCPA opt-out banner
|
|
27
|
+
policyPackPresets.worldNoBanner(), // No banner elsewhere
|
|
28
|
+
];
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
|Preset|Model|UI|Matches|
|
|
32
|
+
|--|--|--|--|
|
|
33
|
+
|`europeOptIn()`|`opt-in`|banner|EEA + UK countries + geo fallback|
|
|
34
|
+
|`europeIab()`|`iab`|banner|EEA + UK countries + geo fallback (TCF 2.3)|
|
|
35
|
+
|`californiaOptOut()`|`opt-out`|none|US-CA region|
|
|
36
|
+
|`quebecOptIn()`|`opt-in`|banner|CA-QC region|
|
|
37
|
+
|`worldNoBanner()`|`none`|none|default fallback|
|
|
38
|
+
|
|
39
|
+
Most apps only need these presets — pick the ones that match your regions, pass them to your backend config or provider, and you're done. Customize individual fields or write fully custom policies when you need more control.
|
|
40
|
+
|
|
41
|
+
For banner/dialog actions, policy packs can also control grouped button arrangement:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
ui: {
|
|
45
|
+
mode: 'banner',
|
|
46
|
+
banner: {
|
|
47
|
+
allowedActions: ['reject', 'accept', 'customize'],
|
|
48
|
+
layout: [['reject', 'accept'], 'customize'],
|
|
49
|
+
direction: 'row',
|
|
50
|
+
primaryActions: ['accept', 'customize'],
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That expresses arrangement only. Button appearance like `stroke`, `filled`, or `ghost` lives in the UI theme.
|
|
56
|
+
|
|
57
|
+
## What Users See
|
|
58
|
+
|
|
59
|
+
Each policy combination produces a different consent experience:
|
|
60
|
+
|
|
61
|
+
|Policy Config|User Experience|
|
|
62
|
+
|--|--|
|
|
63
|
+
|`model: 'opt-in'`, `ui.mode: 'banner'`|Banner appears, nothing loads until the user consents|
|
|
64
|
+
|`model: 'opt-out'`, `ui.mode: 'none'`|No banner, everything loads immediately — user opts out via a "Do Not Sell" link|
|
|
65
|
+
|`model: 'none'`, `ui.mode: 'none'`|No banner, all categories auto-granted silently|
|
|
66
|
+
|`model: 'opt-in'`, `ui.mode: 'dialog'`|Full-screen dialog, nothing loads until the user consents|
|
|
67
|
+
|`model: 'iab'`, `ui.mode: 'banner'`|IAB TCF 2.3 banner with vendor-level controls|
|
|
68
|
+
|
|
69
|
+
## How Policy Resolution Works
|
|
70
|
+
|
|
71
|
+
When a visitor arrives, c15t walks the policy pack in priority order:
|
|
72
|
+
|
|
73
|
+
1. **Match by region** — checks for a policy targeting the specific region (e.g., US-CA, CA-QC)
|
|
74
|
+
2. **Match by country** — if no region match, checks for a country-level policy (e.g., US, DE)
|
|
75
|
+
3. **Fallback (geo failure)** — if geo-location failed (no country detected), uses the policy marked with `match.fallback`
|
|
76
|
+
4. **Fall back to default** — if nothing matches, uses the policy marked as the default
|
|
77
|
+
5. **No match, no default** — resolves to no-banner mode (silent, no consent UI)
|
|
78
|
+
|
|
79
|
+
Within the same matcher type, the first policy in the array wins. Pack order matters when two policies target the same country or region.
|
|
80
|
+
|
|
81
|
+
The **fallback** step is distinct from **default**: `isDefault` is a catch-all for known locations that don't match any specific policy ("rest of world"), while `fallback` is a safety net for unknown locations when geo-headers are missing ("assume strictest"). The `europeOptIn()` and `europeIab()` presets include `fallback: true` by default so EU-level consent applies when geo fails.
|
|
82
|
+
|
|
83
|
+
> ⚠️ **Warning:**
|
|
84
|
+
> Only one default and one fallback policy are allowed. Use inspectPolicies() to surface overlapping matchers and other warnings before deployment.
|
|
85
|
+
|
|
86
|
+
Inspect the resolved policy from any client component:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
'use client';
|
|
90
|
+
|
|
91
|
+
import { useConsentManager } from '@c15t/nextjs';
|
|
92
|
+
|
|
93
|
+
export function PolicyDebug() {
|
|
94
|
+
const { locationInfo, model, policy, policyDecision } = useConsentManager();
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<pre>
|
|
98
|
+
{JSON.stringify(
|
|
99
|
+
{
|
|
100
|
+
country: locationInfo?.countryCode,
|
|
101
|
+
region: locationInfo?.regionCode,
|
|
102
|
+
model,
|
|
103
|
+
policyId: policy?.id,
|
|
104
|
+
matchedBy: policyDecision?.matchedBy,
|
|
105
|
+
fingerprint: policyDecision?.fingerprint,
|
|
106
|
+
},
|
|
107
|
+
null,
|
|
108
|
+
2
|
|
109
|
+
)}
|
|
110
|
+
</pre>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Common Patterns
|
|
116
|
+
|
|
117
|
+
**The 80% case** — strict in Europe, light in California, silent everywhere else:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const policies = [
|
|
121
|
+
{
|
|
122
|
+
id: 'eu',
|
|
123
|
+
match: { countries: ['DE', 'FR', 'IT'] },
|
|
124
|
+
consent: { model: 'opt-in', categories: ['necessary', 'measurement', 'marketing'] },
|
|
125
|
+
ui: { mode: 'banner' },
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'ca',
|
|
129
|
+
match: { regions: [{ country: 'US', region: 'CA' }] },
|
|
130
|
+
consent: { model: 'opt-out', gpc: true },
|
|
131
|
+
ui: { mode: 'none' },
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'default',
|
|
135
|
+
match: { isDefault: true },
|
|
136
|
+
consent: { model: 'none' },
|
|
137
|
+
ui: { mode: 'none' },
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Region overrides country** — stricter rules for California than the rest of the US:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const policies = [
|
|
146
|
+
{
|
|
147
|
+
id: 'us_ca',
|
|
148
|
+
match: { regions: [{ country: 'US', region: 'CA' }] },
|
|
149
|
+
consent: { model: 'opt-in', scopeMode: 'strict' },
|
|
150
|
+
ui: { mode: 'banner' },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'us',
|
|
154
|
+
match: { countries: ['US'] },
|
|
155
|
+
consent: { model: 'opt-out' },
|
|
156
|
+
ui: { mode: 'banner' },
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
// US-CA → us_ca (region match wins)
|
|
160
|
+
// US-NY → us (country match)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Different wording per region** — use `i18n.messageProfile` to vary copy without changing the consent model:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const c15t = c15tInstance({
|
|
167
|
+
i18n: {
|
|
168
|
+
defaultProfile: 'default',
|
|
169
|
+
messages: {
|
|
170
|
+
default: {
|
|
171
|
+
translations: {
|
|
172
|
+
en: { cookieBanner: { title: 'Privacy choices' } },
|
|
173
|
+
es: { cookieBanner: { title: 'Tus opciones de privacidad' } },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
eu: {
|
|
177
|
+
fallbackLanguage: 'en',
|
|
178
|
+
translations: {
|
|
179
|
+
en: { cookieBanner: { title: 'EU GDPR Consent' } },
|
|
180
|
+
fr: { cookieBanner: { title: 'Consentement RGPD' } },
|
|
181
|
+
de: { cookieBanner: { title: 'GDPR-Einwilligung' } },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
policyPacks: [
|
|
187
|
+
{
|
|
188
|
+
id: 'eu',
|
|
189
|
+
match: { countries: ['DE', 'FR', 'IT'] },
|
|
190
|
+
i18n: { messageProfile: 'eu' },
|
|
191
|
+
consent: { model: 'opt-in' },
|
|
192
|
+
ui: { mode: 'banner' },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
In that setup, the `eu` policy uses only the `eu` language set. So Europe can
|
|
199
|
+
resolve to `en`, `fr`, or `de`, but not to `es` or any other locale defined
|
|
200
|
+
only in `default`. If the browser asks for an unsupported locale, c15t falls
|
|
201
|
+
back to the `eu` profile's `fallbackLanguage`.
|
|
202
|
+
|
|
203
|
+
## Re-Prompting on Policy Change
|
|
204
|
+
|
|
205
|
+
When you change a policy in a way that affects consent semantics — like adding a category, changing the consent model, or modifying allowed actions — c15t automatically re-prompts returning users.
|
|
206
|
+
|
|
207
|
+
This works through the **material policy fingerprint**: a hash of only the consent-affecting fields (model, categories, scope, allowed actions, grouped action layout, direction, proof settings). Presentation-only changes like copy, button styling, or scroll lock do not trigger re-prompts.
|
|
208
|
+
|
|
209
|
+
|Change|Re-prompts?|
|
|
210
|
+
|--|--|
|
|
211
|
+
|Add a consent category|Yes|
|
|
212
|
+
|Change `model` from `opt-out` to `opt-in`|Yes|
|
|
213
|
+
|Remove an `allowedAction`|Yes|
|
|
214
|
+
|Change `uiProfile` or button styling|No|
|
|
215
|
+
|Update translation copy|No|
|
|
216
|
+
|Change `scrollLock`|No|
|
|
217
|
+
|
|
218
|
+
## Design Guidelines
|
|
219
|
+
|
|
220
|
+
* **Start from presets.** Use `policyPackPresets` to get running, then customize for your needs.
|
|
221
|
+
* **Keep packs small.** A handful of regional policies is better than dozens of tiny fragments.
|
|
222
|
+
* **Think risk, not geography.** Geography is just a matcher — the real question is what consent behavior each region needs.
|
|
223
|
+
* **Always include a default.** Unless "no banner for unmatched traffic" is intentional.
|
|
224
|
+
* **Set a fallback for geo failures.** Mark your strictest policy with `match.fallback=true` so users in unknown locations still see a consent banner. The `europeOptIn()` and `europeIab()` presets do this automatically.
|
|
225
|
+
* **Keep policy IDs stable.** They appear in debugging output, snapshots, and audit records.
|
|
226
|
+
* **Use `inspectPolicies()` before deploying.** It catches overlapping matchers, missing defaults, and IAB misconfigurations.
|
|
227
|
+
|
|
228
|
+
> ℹ️ **Info:**
|
|
229
|
+
> For provider setup, see the Next.js policy pack guide. For backend configuration, see the self-host guide.
|
package/docs/headless.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Headless Mode
|
|
3
|
+
description: Build fully custom consent UI using only hooks - no pre-built components required.
|
|
4
|
+
---
|
|
5
|
+
c15t's headless mode means using the hooks (`useConsentManager`, `useTranslations`, etc.) without any pre-built UI components. This gives you complete control over the consent experience.
|
|
6
|
+
|
|
7
|
+
There are three levels of customization:
|
|
8
|
+
|
|
9
|
+
1. **Props** - Use pre-built components with custom text and configuration
|
|
10
|
+
2. **noStyle** - Use pre-built components with their structure but strip all styles
|
|
11
|
+
3. **Headless** - Use only hooks and build the entire UI yourself
|
|
12
|
+
|
|
13
|
+
## When to Go Headless
|
|
14
|
+
|
|
15
|
+
Go headless when:
|
|
16
|
+
|
|
17
|
+
* Your design system requires complete control over markup
|
|
18
|
+
* You need a consent flow that doesn't fit the banner/dialog pattern
|
|
19
|
+
* You want to embed consent choices inline rather than as overlays
|
|
20
|
+
|
|
21
|
+
Use `noStyle` instead when:
|
|
22
|
+
|
|
23
|
+
* The component structure works but the styling doesn't
|
|
24
|
+
* You want to apply your own CSS/Tailwind without fighting defaults
|
|
25
|
+
|
|
26
|
+
> ℹ️ **Info:**
|
|
27
|
+
> Need a policy-aware implementation guide? See Building Headless Components.
|
|
28
|
+
|
|
29
|
+
## Full Example: Custom Consent Banner
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { useConsentManager, useTranslations } from '@c15t/nextjs';
|
|
33
|
+
|
|
34
|
+
function CustomConsentBanner() {
|
|
35
|
+
const {
|
|
36
|
+
activeUI,
|
|
37
|
+
consents,
|
|
38
|
+
consentCategories,
|
|
39
|
+
consentTypes,
|
|
40
|
+
saveConsents,
|
|
41
|
+
setSelectedConsent,
|
|
42
|
+
selectedConsents,
|
|
43
|
+
} = useConsentManager();
|
|
44
|
+
const translations = useTranslations();
|
|
45
|
+
|
|
46
|
+
if (activeUI !== 'banner') return null;
|
|
47
|
+
|
|
48
|
+
const displayedTypes = consentTypes.filter(
|
|
49
|
+
(t) => consentCategories.includes(t.name) && t.display
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="fixed bottom-0 inset-x-0 bg-white border-t p-6 shadow-lg z-50">
|
|
54
|
+
<h2 className="text-lg font-semibold">
|
|
55
|
+
{translations.cookieBanner.title}
|
|
56
|
+
</h2>
|
|
57
|
+
<p className="text-sm text-gray-600 mt-1">
|
|
58
|
+
{translations.cookieBanner.description}
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
<div className="mt-4 space-y-3">
|
|
62
|
+
{displayedTypes.map((type) => (
|
|
63
|
+
<label key={type.name} className="flex items-center gap-3">
|
|
64
|
+
<input
|
|
65
|
+
type="checkbox"
|
|
66
|
+
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
|
|
67
|
+
disabled={type.disabled}
|
|
68
|
+
onChange={(e) => setSelectedConsent(type.name, e.target.checked)}
|
|
69
|
+
/>
|
|
70
|
+
<div>
|
|
71
|
+
<span className="font-medium">
|
|
72
|
+
{translations.consentTypes[type.name]?.title ?? type.name}
|
|
73
|
+
</span>
|
|
74
|
+
<p className="text-xs text-gray-500">{type.description}</p>
|
|
75
|
+
</div>
|
|
76
|
+
</label>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="mt-4 flex gap-3">
|
|
81
|
+
<button
|
|
82
|
+
onClick={() => saveConsents('necessary')}
|
|
83
|
+
className="px-4 py-2 border rounded"
|
|
84
|
+
>
|
|
85
|
+
{translations.common.rejectAll}
|
|
86
|
+
</button>
|
|
87
|
+
<button
|
|
88
|
+
onClick={() => saveConsents('custom')}
|
|
89
|
+
className="px-4 py-2 border rounded"
|
|
90
|
+
>
|
|
91
|
+
{translations.common.save}
|
|
92
|
+
</button>
|
|
93
|
+
<button
|
|
94
|
+
onClick={() => saveConsents('all')}
|
|
95
|
+
className="px-4 py-2 bg-blue-600 text-white rounded"
|
|
96
|
+
>
|
|
97
|
+
{translations.common.acceptAll}
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Usage with Provider
|
|
106
|
+
|
|
107
|
+
The headless UI still needs a `ConsentManagerProvider`:
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
import { type ReactNode } from 'react';
|
|
111
|
+
import { ConsentManagerProvider } from '@c15t/nextjs';
|
|
112
|
+
|
|
113
|
+
export function ConsentManager({ children }: { children: ReactNode }) {
|
|
114
|
+
return (
|
|
115
|
+
<ConsentManagerProvider
|
|
116
|
+
options={{
|
|
117
|
+
mode: 'hosted',
|
|
118
|
+
backendURL: '/api/c15t',
|
|
119
|
+
consentCategories: ['necessary', 'measurement', 'marketing'],
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<CustomConsentBanner />
|
|
123
|
+
{children}
|
|
124
|
+
</ConsentManagerProvider>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Custom Dialog
|
|
130
|
+
|
|
131
|
+
Build a custom consent dialog for detailed category management:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { useConsentManager, useTranslations } from '@c15t/nextjs';
|
|
135
|
+
|
|
136
|
+
function CustomConsentDialog() {
|
|
137
|
+
const {
|
|
138
|
+
activeUI,
|
|
139
|
+
setActiveUI,
|
|
140
|
+
consentTypes,
|
|
141
|
+
consentCategories,
|
|
142
|
+
selectedConsents,
|
|
143
|
+
consents,
|
|
144
|
+
setSelectedConsent,
|
|
145
|
+
saveConsents,
|
|
146
|
+
has,
|
|
147
|
+
} = useConsentManager();
|
|
148
|
+
const translations = useTranslations();
|
|
149
|
+
|
|
150
|
+
if (activeUI !== 'dialog') return null;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
154
|
+
<div className="absolute inset-0 bg-black/50" onClick={() => setActiveUI('none')} />
|
|
155
|
+
<div className="relative bg-white rounded-xl p-6 max-w-md w-full">
|
|
156
|
+
<h2 className="text-lg font-semibold">{translations.consentManagerDialog.title}</h2>
|
|
157
|
+
|
|
158
|
+
{consentTypes
|
|
159
|
+
.filter((t) => consentCategories.includes(t.name))
|
|
160
|
+
.map((type) => (
|
|
161
|
+
<div key={type.name} className="flex items-center justify-between py-3 border-b">
|
|
162
|
+
<div>
|
|
163
|
+
<p className="font-medium">{translations.consentTypes[type.name]?.title}</p>
|
|
164
|
+
<p className="text-sm text-gray-500">{type.description}</p>
|
|
165
|
+
</div>
|
|
166
|
+
<input
|
|
167
|
+
type="checkbox"
|
|
168
|
+
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
|
|
169
|
+
disabled={type.disabled}
|
|
170
|
+
onChange={(e) => setSelectedConsent(type.name, e.target.checked)}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
))}
|
|
174
|
+
|
|
175
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
176
|
+
<button onClick={() => saveConsents('necessary')}>Reject</button>
|
|
177
|
+
<button onClick={() => saveConsents('custom')}>Save</button>
|
|
178
|
+
<button onClick={() => saveConsents('all')}>Accept All</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: useColorScheme
|
|
3
|
+
description: Manage light/dark mode preferences for consent components.
|
|
4
|
+
---
|
|
5
|
+
`useColorScheme()` manages the color scheme preference for c15t components. It sets up the appropriate CSS class and media query listeners.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { useColorScheme } from '@c15t/nextjs';
|
|
9
|
+
|
|
10
|
+
function ThemeManager() {
|
|
11
|
+
useColorScheme('system'); // Follow system preference
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Parameters
|
|
16
|
+
|
|
17
|
+
|Value|Behavior|
|
|
18
|
+
|--|--|
|
|
19
|
+
|`'light'`|Force light mode|
|
|
20
|
+
|`'dark'`|Force dark mode|
|
|
21
|
+
|`'system'`|Follow `prefers-color-scheme` media query|
|
|
22
|
+
|`null`|Disable - c15t won't manage color scheme|
|
|
23
|
+
|`undefined`|No-op|
|
|
24
|
+
|
|
25
|
+
## Provider-Level Configuration
|
|
26
|
+
|
|
27
|
+
You can also set the color scheme on the provider without using this hook:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
<ConsentManagerProvider
|
|
31
|
+
options={{
|
|
32
|
+
colorScheme: 'system',
|
|
33
|
+
// ...
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## System Preference Detection
|
|
39
|
+
|
|
40
|
+
When set to `'system'`, the hook listens for changes to the `prefers-color-scheme` media query and updates automatically when the user changes their OS theme.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Checking Consent
|
|
3
|
+
description: Use has() for flexible consent checks with AND, OR, and NOT logic. Check if any consent exists with hasConsented().
|
|
4
|
+
---
|
|
5
|
+
## has(condition)
|
|
6
|
+
|
|
7
|
+
The `has()` method evaluates whether the current consent state satisfies a condition. It supports simple category checks and complex logical expressions.
|
|
8
|
+
|
|
9
|
+
### Simple Check
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
const { has } = useConsentManager();
|
|
13
|
+
|
|
14
|
+
if (has('measurement')) {
|
|
15
|
+
// User has granted measurement consent
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### AND Logic
|
|
20
|
+
|
|
21
|
+
All conditions must be true:
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
has({ and: ['measurement', 'marketing'] })
|
|
25
|
+
// true only if BOTH measurement AND marketing are granted
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### OR Logic
|
|
29
|
+
|
|
30
|
+
At least one condition must be true:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
has({ or: ['measurement', 'marketing'] })
|
|
34
|
+
// true if EITHER measurement OR marketing is granted
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### NOT Logic
|
|
38
|
+
|
|
39
|
+
Negates a condition:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
has({ not: 'marketing' })
|
|
43
|
+
// true if marketing consent is NOT granted
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Nested Conditions
|
|
47
|
+
|
|
48
|
+
Combine operators for complex logic:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
has({
|
|
52
|
+
and: [
|
|
53
|
+
'necessary',
|
|
54
|
+
{ or: ['measurement', 'marketing'] },
|
|
55
|
+
{ not: 'functionality' },
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
// true if: necessary AND (measurement OR marketing) AND NOT functionality
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### HasCondition Type
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
type HasCondition<CategoryType> =
|
|
65
|
+
| CategoryType // "measurement"
|
|
66
|
+
| { and: HasCondition[] | HasCondition } // { and: ["a", "b"] }
|
|
67
|
+
| { or: HasCondition[] | HasCondition } // { or: ["a", "b"] }
|
|
68
|
+
| { not: HasCondition } // { not: "a" }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## hasConsented()
|
|
72
|
+
|
|
73
|
+
Returns `true` if the user has made any consent choice (accepted, rejected, or customized). Returns `false` if no consent has been recorded yet.
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
const { hasConsented } = useConsentManager();
|
|
77
|
+
|
|
78
|
+
if (hasConsented()) {
|
|
79
|
+
// User has previously made a consent choice
|
|
80
|
+
} else {
|
|
81
|
+
// First visit — no consent recorded
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## getDisplayedConsents()
|
|
86
|
+
|
|
87
|
+
Returns the consent types that should be displayed in the UI (based on active `consentCategories` and each type's `display` property):
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
const { getDisplayedConsents } = useConsentManager();
|
|
91
|
+
|
|
92
|
+
const visibleCategories = getDisplayedConsents();
|
|
93
|
+
// Returns ConsentType[] with name, description, defaultValue, etc.
|
|
94
|
+
```
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Location Info
|
|
3
|
+
description: Access detected jurisdiction, country, and region. Override geolocation for testing.
|
|
4
|
+
---
|
|
5
|
+
## locationInfo
|
|
6
|
+
|
|
7
|
+
The `locationInfo` state contains the user's detected geographic information:
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
const { locationInfo } = useConsentManager();
|
|
11
|
+
|
|
12
|
+
if (locationInfo) {
|
|
13
|
+
console.log(locationInfo.jurisdiction); // 'GDPR', 'CCPA', etc.
|
|
14
|
+
console.log(locationInfo.countryCode); // 'DE', 'US', etc.
|
|
15
|
+
console.log(locationInfo.regionCode); // 'BY', 'CA', etc.
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`locationInfo` is `null` until the backend responds with geolocation data (or in offline mode if no overrides are set).
|
|
20
|
+
|
|
21
|
+
## Jurisdiction Codes
|
|
22
|
+
|
|
23
|
+
|Code|Region|Consent Model|
|
|
24
|
+
|--|--|--|
|
|
25
|
+
|`GDPR`|European Union|opt-in|
|
|
26
|
+
|`UK_GDPR`|United Kingdom|opt-in|
|
|
27
|
+
|`CH`|Switzerland|opt-in|
|
|
28
|
+
|`BR`|Brazil (LGPD)|opt-in|
|
|
29
|
+
|`APPI`|Japan|opt-in|
|
|
30
|
+
|`PIPA`|South Korea|opt-in|
|
|
31
|
+
|`PIPEDA`|Canada (excl. Quebec)|opt-out|
|
|
32
|
+
|`QC_LAW25`|Quebec, Canada|opt-in|
|
|
33
|
+
|`CCPA`|California, USA|opt-out|
|
|
34
|
+
|`AU`|Australia|opt-out|
|
|
35
|
+
|`NONE`|No jurisdiction|null model|
|
|
36
|
+
|
|
37
|
+
## setOverrides()
|
|
38
|
+
|
|
39
|
+
Override detected values for testing or manual configuration. This triggers a re-fetch of consent banner data with the new values:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
const { setOverrides } = useConsentManager();
|
|
43
|
+
|
|
44
|
+
// Override country (triggers jurisdiction detection)
|
|
45
|
+
await setOverrides({ country: 'DE' });
|
|
46
|
+
|
|
47
|
+
// Override language
|
|
48
|
+
await setOverrides({ language: 'de' });
|
|
49
|
+
|
|
50
|
+
// Override both
|
|
51
|
+
await setOverrides({ country: 'US', region: 'CA', language: 'es' });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## setLocationInfo()
|
|
55
|
+
|
|
56
|
+
Directly set location info without triggering a re-fetch:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
const { setLocationInfo } = useConsentManager();
|
|
60
|
+
|
|
61
|
+
setLocationInfo({
|
|
62
|
+
jurisdiction: 'GDPR',
|
|
63
|
+
countryCode: 'DE',
|
|
64
|
+
regionCode: 'BY',
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Testing Different Jurisdictions
|
|
69
|
+
|
|
70
|
+
A development-only component for testing consent behavior across jurisdictions:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
function JurisdictionTester() {
|
|
74
|
+
const { setOverrides, model, locationInfo } = useConsentManager();
|
|
75
|
+
|
|
76
|
+
const testCases = [
|
|
77
|
+
{ label: 'GDPR', country: 'DE' },
|
|
78
|
+
{ label: 'CCPA', country: 'US', region: 'CA' },
|
|
79
|
+
{ label: 'PIPEDA', country: 'CA', region: undefined },
|
|
80
|
+
{ label: 'QC_LAW25', country: 'CA', region: 'QC' },
|
|
81
|
+
{ label: 'NONE', country: 'US', region: 'TX' },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div>
|
|
86
|
+
<p>Current: {locationInfo?.jurisdiction ?? 'none'} → model: {model}</p>
|
|
87
|
+
{testCases.map((tc) => (
|
|
88
|
+
<button key={tc.label} onClick={() => setOverrides({ country: tc.country, region: tc.region })}>
|
|
89
|
+
Test as {tc.label}
|
|
90
|
+
</button>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|