first commit
This commit is contained in:
commit
48cabcf293
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
4
.npmignore
Normal file
4
.npmignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 PDN Technology
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
325
README.md
Normal file
325
README.md
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
# @pdntechnology/cookie-consent
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.npmjs.com/package/@pdntechnology/cookie-consent)
|
||||||
|
|
||||||
|
A lightweight, open-source **Consent Management Platform (CMP)** for React and Next.js that implements [Google Consent Mode v2](https://developers.google.com/tag-platform/security/guides/consent) the same way certified tools do.
|
||||||
|
|
||||||
|
No heavy dependencies. No vendor lock-in. Works with Google Tag Manager, GA4, Google Ads, and Meta Pixel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Google-certified CMPs (Cookiebot, OneTrust, CookieYes, …) follow the same technical flow:
|
||||||
|
|
||||||
|
1. Set **denied** defaults **before** any Google tag loads
|
||||||
|
2. Load GTM / gtag.js
|
||||||
|
3. Show a consent banner
|
||||||
|
4. On user choice → `gtag('consent', 'update', …)` + dataLayer event
|
||||||
|
5. When ads are denied → enable `ads_data_redaction` and `url_passthrough`
|
||||||
|
|
||||||
|
This library implements that flow in ~15 KB of readable JavaScript.
|
||||||
|
|
||||||
|
> **Note:** Certified CMPs additionally provide IAB TCF v2.2 signals and appear on Google's certified vendor list. This library covers the **Consent Mode v2 technical integration**. You are responsible for privacy policy text and legal compliance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Google Consent Mode v2 (all 7 signals)
|
||||||
|
- Granular categories: Necessary / Analytics / Marketing
|
||||||
|
- `ads_data_redaction` + `url_passthrough` when marketing is denied
|
||||||
|
- Regional default consent (EEA, etc.)
|
||||||
|
- React banner + Next.js bootstrap component
|
||||||
|
- Optional GTM and Meta Pixel loaders (consent-gated)
|
||||||
|
- Framework-agnostic core API (vanilla JS supported)
|
||||||
|
- TypeScript definitions included
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @pdntechnology/cookie-consent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next.js setup
|
||||||
|
|
||||||
|
Add to `next.config.mjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ["@pdntechnology/cookie-consent"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### 1. Configure (optional)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/consent-config.js
|
||||||
|
import { configureConsent } from "@pdntechnology/cookie-consent";
|
||||||
|
|
||||||
|
configureConsent({
|
||||||
|
storageKey: "my_site_consent", // localStorage key
|
||||||
|
waitForUpdateMs: 500, // time GTM waits for consent update
|
||||||
|
adsDataRedaction: true, // Google recommendation when ads denied
|
||||||
|
urlPassthrough: true, // pass click IDs in URL when cookies denied
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Import this file in your root layout **before** `ConsentBootstrap`.
|
||||||
|
|
||||||
|
### 2. Bootstrap consent defaults (before GTM)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/layout.jsx
|
||||||
|
import "@pdntechnology/cookie-consent/styles.css";
|
||||||
|
import "@/lib/consent-config";
|
||||||
|
import ConsentBootstrap from "@pdntechnology/cookie-consent/react/ConsentBootstrap";
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<ConsentBootstrap />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Banner + tags
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/providers.jsx
|
||||||
|
import {
|
||||||
|
CookieConsent,
|
||||||
|
GoogleTagManager,
|
||||||
|
FacebookPixel,
|
||||||
|
} from "@pdntechnology/cookie-consent/react";
|
||||||
|
|
||||||
|
export function Providers({ children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<CookieConsent
|
||||||
|
policyHref="/privacy"
|
||||||
|
labels={{
|
||||||
|
accept: "Accept all",
|
||||||
|
reject: "Reject all",
|
||||||
|
manage: "Manage preferences",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<GoogleTagManager gtmId="GTM-XXXXXXX" />
|
||||||
|
<FacebookPixel pixelId="123456789" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Re-open preferences (footer link)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { openPreferences } from "@pdntechnology/cookie-consent";
|
||||||
|
|
||||||
|
<button type="button" onClick={openPreferences}>
|
||||||
|
Cookie settings
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How categories map to Google signals
|
||||||
|
|
||||||
|
| Banner category | User choice | Google Consent Mode signals |
|
||||||
|
|-----------------|-------------|----------------------------|
|
||||||
|
| **Necessary** | Always on | `security_storage: granted` |
|
||||||
|
| **Analytics** | Opt-in | `analytics_storage` |
|
||||||
|
| **Marketing** | Opt-in | `ad_storage`, `ad_user_data`, `ad_personalization`, `functionality_storage`, `personalization_storage` |
|
||||||
|
|
||||||
|
When marketing is **denied**, the library also sets:
|
||||||
|
|
||||||
|
- `ads_data_redaction: true`
|
||||||
|
- `url_passthrough: true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.js Public API
|
||||||
|
├── config.js configureConsent()
|
||||||
|
├── constants.js Events, signal names
|
||||||
|
├── preferences.js Parse / serialize user choices
|
||||||
|
├── storage.js localStorage read/write
|
||||||
|
├── consent-api.js acceptAll, rejectAll, getConsent, …
|
||||||
|
├── google/
|
||||||
|
│ ├── gtag.js dataLayer helpers
|
||||||
|
│ ├── consent-signals.js Category → signal mapping
|
||||||
|
│ ├── consent-lifecycle.js default + update calls
|
||||||
|
│ └── bootstrap-script.js Inline <head> script generator
|
||||||
|
└── react/
|
||||||
|
├── ConsentBootstrap.jsx
|
||||||
|
├── CookieConsent.jsx
|
||||||
|
├── GoogleTagManager.jsx
|
||||||
|
└── FacebookPixel.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `configureConsent(config)` | Set storage key, wait time, region defaults |
|
||||||
|
| `getConsentConfig()` | Read active configuration |
|
||||||
|
|
||||||
|
### User consent
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `getConsent()` | Read stored preferences |
|
||||||
|
| `setConsent({ analytics, marketing })` | Save custom choice |
|
||||||
|
| `acceptAll()` | Grant all categories |
|
||||||
|
| `rejectAll()` | Deny optional categories |
|
||||||
|
| `hasAnalyticsConsent()` | Check analytics opt-in |
|
||||||
|
| `hasMarketingConsent()` | Check marketing opt-in |
|
||||||
|
| `openPreferences()` | Re-open banner panel |
|
||||||
|
| `syncConsentFromStorage()` | Re-apply stored consent after GTM loads |
|
||||||
|
|
||||||
|
### Google Consent Mode
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `getConsentBootstrapScript()` | Inline script for vanilla HTML |
|
||||||
|
| `applyConsentDefaults()` | Set denied defaults programmatically |
|
||||||
|
| `applyConsentUpdate(consent)` | Push user choice to gtag |
|
||||||
|
| `buildGoogleConsentState(consent)` | Get signal object |
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
| Constant | Fired when |
|
||||||
|
|----------|------------|
|
||||||
|
| `EVENT_CONSENT_UPDATED` | User saves a choice |
|
||||||
|
| `EVENT_OPEN_PREFERENCES` | Preferences panel should open |
|
||||||
|
| `GTM_CONSENT_EVENT` | Pushed to dataLayer (`cookie_consent_update`) |
|
||||||
|
|
||||||
|
Legacy aliases (`getCookieConsent`, `acceptAllCookies`, …) remain available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vanilla HTML (no React)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('consent', 'default', {
|
||||||
|
ad_storage: 'denied',
|
||||||
|
analytics_storage: 'denied',
|
||||||
|
ad_user_data: 'denied',
|
||||||
|
ad_personalization: 'denied',
|
||||||
|
functionality_storage: 'denied',
|
||||||
|
personalization_storage: 'denied',
|
||||||
|
security_storage: 'granted',
|
||||||
|
wait_for_update: 500
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<!-- GTM snippet here -->
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or generate the script programmatically:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { getConsentBootstrapScript } from "@pdntechnology/cookie-consent";
|
||||||
|
console.log(getConsentBootstrapScript());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GTM integration checklist
|
||||||
|
|
||||||
|
1. Enable **Consent Mode** in your GTM container settings
|
||||||
|
2. Configure GA4 / Google Ads tags to require appropriate consent signals
|
||||||
|
3. Optional: create a trigger on custom event `cookie_consent_update`
|
||||||
|
4. Test with [Tag Assistant](https://tagassistant.google.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regional defaults (EEA example)
|
||||||
|
|
||||||
|
```js
|
||||||
|
configureConsent({
|
||||||
|
regionDefaults: [
|
||||||
|
{
|
||||||
|
regions: ["AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
||||||
|
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
||||||
|
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO"],
|
||||||
|
ad_storage: "denied",
|
||||||
|
analytics_storage: "denied",
|
||||||
|
ad_user_data: "denied",
|
||||||
|
ad_personalization: "denied",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local development (link to another project)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In this repo
|
||||||
|
cd cookie-consent
|
||||||
|
npm link
|
||||||
|
|
||||||
|
# In your Next.js app
|
||||||
|
npm link @pdntechnology/cookie-consent
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a file dependency:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@pdntechnology/cookie-consent": "file:../cookie-consent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Publishing to npm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm login
|
||||||
|
npm publish --access public
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome. Please open an issue before large changes.
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Submit a pull request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © [PDN Technology](https://pdntechnology.com)
|
||||||
59
package.json
Normal file
59
package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"name": "@pdntechnology/cookie-consent",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Lightweight Google Consent Mode v2 CMP for React and Next.js",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.js",
|
||||||
|
"module": "./src/index.js",
|
||||||
|
"types": "./src/index.d.ts",
|
||||||
|
"sideEffects": ["./src/styles.css"],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./src/index.js",
|
||||||
|
"default": "./src/index.js"
|
||||||
|
},
|
||||||
|
"./react": {
|
||||||
|
"import": "./src/react/index.js",
|
||||||
|
"default": "./src/react/index.js"
|
||||||
|
},
|
||||||
|
"./styles.css": "./src/styles.css"
|
||||||
|
},
|
||||||
|
"files": ["src", "README.md", "LICENSE"],
|
||||||
|
"scripts": {
|
||||||
|
"prepublishOnly": "test -f README.md && test -f LICENSE"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": ">=14.0.0",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-dom": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"next": { "optional": true },
|
||||||
|
"react": { "optional": true },
|
||||||
|
"react-dom": { "optional": true }
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"cookie-consent",
|
||||||
|
"consent-mode",
|
||||||
|
"google-consent-mode",
|
||||||
|
"consent-mode-v2",
|
||||||
|
"gtm",
|
||||||
|
"google-tag-manager",
|
||||||
|
"gdpr",
|
||||||
|
"cmp",
|
||||||
|
"react",
|
||||||
|
"nextjs"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/pdntechnology/cookie-consent.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/pdntechnology/cookie-consent/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/pdntechnology/cookie-consent#readme",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/config.js
Normal file
35
src/config.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { DEFAULT_STORAGE_KEY } from "./constants.js";
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
storageKey: DEFAULT_STORAGE_KEY,
|
||||||
|
waitForUpdateMs: 500,
|
||||||
|
adsDataRedaction: true,
|
||||||
|
urlPassthrough: true,
|
||||||
|
regionDefaults: [],
|
||||||
|
debug: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let activeConfig = { ...DEFAULTS };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure global behaviour. Call once at app startup, before ConsentBootstrap.
|
||||||
|
* @param {import('./types.js').ConsentConfig} userConfig
|
||||||
|
*/
|
||||||
|
export function configureConsent(userConfig = {}) {
|
||||||
|
activeConfig = {
|
||||||
|
...DEFAULTS,
|
||||||
|
...userConfig,
|
||||||
|
regionDefaults: Array.isArray(userConfig.regionDefaults) ? userConfig.regionDefaults : [],
|
||||||
|
};
|
||||||
|
return getConsentConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConsentConfig() {
|
||||||
|
return { ...activeConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageKey() {
|
||||||
|
return activeConfig.storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULTS as defaultConfig };
|
||||||
69
src/consent-api.js
Normal file
69
src/consent-api.js
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { EVENT_CONSENT_UPDATED, EVENT_OPEN_PREFERENCES } from "./constants.js";
|
||||||
|
import { applyConsentUpdate } from "./google/consent-lifecycle.js";
|
||||||
|
import { parseUserConsent } from "./preferences.js";
|
||||||
|
import { readStoredConsent, writeStoredConsent } from "./storage.js";
|
||||||
|
|
||||||
|
// ─── Read ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getConsent() {
|
||||||
|
return readStoredConsent();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasConsentChoice() {
|
||||||
|
return readStoredConsent() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnalyticsConsent() {
|
||||||
|
return readStoredConsent()?.analytics === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMarketingConsent() {
|
||||||
|
return readStoredConsent()?.marketing === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Write ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function persistConsent(preferences) {
|
||||||
|
const consent = parseUserConsent(preferences);
|
||||||
|
if (!consent) return;
|
||||||
|
|
||||||
|
if (!writeStoredConsent(consent)) return;
|
||||||
|
|
||||||
|
applyConsentUpdate(consent);
|
||||||
|
window.dispatchEvent(new CustomEvent(EVENT_CONSENT_UPDATED, { detail: consent }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConsent(preferences) {
|
||||||
|
persistConsent(preferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceptAll() {
|
||||||
|
persistConsent({ analytics: true, marketing: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectAll() {
|
||||||
|
persistConsent({ analytics: false, marketing: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sync & UI ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Re-apply stored consent after GTM loads (e.g. returning visitor). */
|
||||||
|
export function syncConsentFromStorage() {
|
||||||
|
const consent = readStoredConsent();
|
||||||
|
if (consent) applyConsentUpdate(consent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPreferences() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.dispatchEvent(new CustomEvent(EVENT_OPEN_PREFERENCES));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy aliases (backward compatible) ───────────────────────────────────
|
||||||
|
|
||||||
|
export const getCookieConsent = getConsent;
|
||||||
|
export const setCookieConsent = setConsent;
|
||||||
|
export const acceptAllCookies = acceptAll;
|
||||||
|
export const rejectAllCookies = rejectAll;
|
||||||
|
export const hasCookieConsentChoice = hasConsentChoice;
|
||||||
|
export const syncGoogleConsentFromStorage = syncConsentFromStorage;
|
||||||
|
export const openCookiePreferences = openPreferences;
|
||||||
33
src/constants.js
Normal file
33
src/constants.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/** localStorage key (override via configureConsent) */
|
||||||
|
export const DEFAULT_STORAGE_KEY = "cookie_consent_preferences";
|
||||||
|
|
||||||
|
/** Fired when the user saves a consent choice */
|
||||||
|
export const EVENT_CONSENT_UPDATED = "cookie-consent:updated";
|
||||||
|
|
||||||
|
/** Fired to reopen the preferences panel */
|
||||||
|
export const EVENT_OPEN_PREFERENCES = "cookie-consent:open-preferences";
|
||||||
|
|
||||||
|
/** GTM / dataLayer custom event name */
|
||||||
|
export const GTM_CONSENT_EVENT = "cookie_consent_update";
|
||||||
|
|
||||||
|
/** All Google Consent Mode v2 signal names */
|
||||||
|
export const GOOGLE_CONSENT_SIGNALS = [
|
||||||
|
"ad_storage",
|
||||||
|
"analytics_storage",
|
||||||
|
"ad_user_data",
|
||||||
|
"ad_personalization",
|
||||||
|
"functionality_storage",
|
||||||
|
"personalization_storage",
|
||||||
|
"security_storage",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Default denied state — security_storage stays granted (required cookies) */
|
||||||
|
export const GOOGLE_DEFAULT_DENIED = {
|
||||||
|
ad_storage: "denied",
|
||||||
|
analytics_storage: "denied",
|
||||||
|
ad_user_data: "denied",
|
||||||
|
ad_personalization: "denied",
|
||||||
|
functionality_storage: "denied",
|
||||||
|
personalization_storage: "denied",
|
||||||
|
security_storage: "granted",
|
||||||
|
};
|
||||||
29
src/google/bootstrap-script.js
vendored
Normal file
29
src/google/bootstrap-script.js
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { getConsentConfig } from "../config.js";
|
||||||
|
import { getDefaultDeniedState } from "./consent-signals.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline script for `<head>` — must execute before GTM snippet.
|
||||||
|
* Used by ConsentBootstrap and vanilla HTML integrations.
|
||||||
|
*/
|
||||||
|
export function getConsentBootstrapScript(overrides = {}) {
|
||||||
|
const config = { ...getConsentConfig(), ...overrides };
|
||||||
|
const wait = Number(config.waitForUpdateMs) || 500;
|
||||||
|
|
||||||
|
const regionLines = (config.regionDefaults || [])
|
||||||
|
.filter((block) => Array.isArray(block.regions) && block.regions.length > 0)
|
||||||
|
.map((block) => {
|
||||||
|
const { regions, ...signals } = block;
|
||||||
|
const payload = JSON.stringify({ ...getDefaultDeniedState(wait), ...signals, region: regions });
|
||||||
|
return `gtag('consent','default',${payload});`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const globalDefault = JSON.stringify(getDefaultDeniedState(wait));
|
||||||
|
|
||||||
|
return `
|
||||||
|
window.dataLayer=window.dataLayer||[];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('consent','default',${globalDefault});
|
||||||
|
${regionLines}
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
53
src/google/consent-lifecycle.js
Normal file
53
src/google/consent-lifecycle.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { getConsentConfig } from "../config.js";
|
||||||
|
import { GTM_CONSENT_EVENT } from "../constants.js";
|
||||||
|
import { ensureGtag, pushDataLayerEvent } from "./gtag.js";
|
||||||
|
import { buildAdvancedSettings, buildConsentSignals, getDefaultDeniedState } from "./consent-signals.js";
|
||||||
|
|
||||||
|
function logDebug(message, payload) {
|
||||||
|
if (getConsentConfig().debug) {
|
||||||
|
console.info("[cookie-consent]", message, payload ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply denied defaults — must run before GTM / gtag.js loads. */
|
||||||
|
export function applyConsentDefaults() {
|
||||||
|
const gtag = ensureGtag();
|
||||||
|
if (!gtag) return;
|
||||||
|
|
||||||
|
const { waitForUpdateMs, regionDefaults } = getConsentConfig();
|
||||||
|
|
||||||
|
gtag("consent", "default", getDefaultDeniedState(waitForUpdateMs));
|
||||||
|
|
||||||
|
for (const block of regionDefaults) {
|
||||||
|
if (!Array.isArray(block.regions) || block.regions.length === 0) continue;
|
||||||
|
const { regions, ...overrides } = block;
|
||||||
|
gtag("consent", "default", {
|
||||||
|
...getDefaultDeniedState(waitForUpdateMs),
|
||||||
|
...overrides,
|
||||||
|
region: regions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug("consent defaults applied");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push user's choice to Google — call after banner interaction. */
|
||||||
|
export function applyConsentUpdate(consent) {
|
||||||
|
const gtag = ensureGtag();
|
||||||
|
if (!gtag) return;
|
||||||
|
|
||||||
|
const config = getConsentConfig();
|
||||||
|
const signals = buildConsentSignals(consent);
|
||||||
|
const advanced = buildAdvancedSettings(consent, config);
|
||||||
|
|
||||||
|
gtag("consent", "update", { ...signals, ...advanced });
|
||||||
|
|
||||||
|
pushDataLayerEvent({
|
||||||
|
event: GTM_CONSENT_EVENT,
|
||||||
|
consent: signals,
|
||||||
|
consent_analytics: consent?.analytics === true,
|
||||||
|
consent_marketing: consent?.marketing === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logDebug("consent updated", { signals, advanced });
|
||||||
|
}
|
||||||
54
src/google/consent-signals.js
Normal file
54
src/google/consent-signals.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { GOOGLE_DEFAULT_DENIED } from "../constants.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map CMP categories to Google Consent Mode v2 signals.
|
||||||
|
*
|
||||||
|
* | Category | Google signals |
|
||||||
|
* |------------|-------------------------------------------------------------|
|
||||||
|
* | Necessary | security_storage → granted (always) |
|
||||||
|
* | Analytics | analytics_storage |
|
||||||
|
* | Marketing | ad_storage, ad_user_data, ad_personalization, |
|
||||||
|
* | | functionality_storage, personalization_storage |
|
||||||
|
*
|
||||||
|
* @param {{ analytics?: boolean, marketing?: boolean }|null|undefined} consent
|
||||||
|
*/
|
||||||
|
export function buildConsentSignals(consent) {
|
||||||
|
const analytics = consent?.analytics ? "granted" : "denied";
|
||||||
|
const marketing = consent?.marketing ? "granted" : "denied";
|
||||||
|
|
||||||
|
return {
|
||||||
|
analytics_storage: analytics,
|
||||||
|
ad_storage: marketing,
|
||||||
|
ad_user_data: marketing,
|
||||||
|
ad_personalization: marketing,
|
||||||
|
functionality_storage: marketing,
|
||||||
|
personalization_storage: marketing,
|
||||||
|
security_storage: "granted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced settings Google recommends when ads are denied.
|
||||||
|
* @param {{ marketing?: boolean }|null|undefined} consent
|
||||||
|
* @param {{ adsDataRedaction?: boolean, urlPassthrough?: boolean }} options
|
||||||
|
*/
|
||||||
|
export function buildAdvancedSettings(consent, options) {
|
||||||
|
const marketingGranted = consent?.marketing === true;
|
||||||
|
const settings = {};
|
||||||
|
|
||||||
|
if (options.adsDataRedaction) {
|
||||||
|
settings.ads_data_redaction = !marketingGranted;
|
||||||
|
}
|
||||||
|
if (options.urlPassthrough) {
|
||||||
|
settings.url_passthrough = !marketingGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultDeniedState(waitForUpdateMs) {
|
||||||
|
return {
|
||||||
|
...GOOGLE_DEFAULT_DENIED,
|
||||||
|
wait_for_update: waitForUpdateMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
src/google/gtag.js
Normal file
19
src/google/gtag.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/** Ensure window.dataLayer and window.gtag exist (Google Tag / GTM pattern). */
|
||||||
|
export function ensureGtag() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
window.gtag =
|
||||||
|
window.gtag ||
|
||||||
|
function gtag() {
|
||||||
|
window.dataLayer.push(arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
return window.gtag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushDataLayerEvent(payload) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
window.dataLayer.push(payload);
|
||||||
|
}
|
||||||
64
src/index.d.ts
vendored
Normal file
64
src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
export interface UserConsent {
|
||||||
|
analytics: boolean;
|
||||||
|
marketing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionDefault {
|
||||||
|
regions: string[];
|
||||||
|
ad_storage?: "granted" | "denied";
|
||||||
|
analytics_storage?: "granted" | "denied";
|
||||||
|
ad_user_data?: "granted" | "denied";
|
||||||
|
ad_personalization?: "granted" | "denied";
|
||||||
|
functionality_storage?: "granted" | "denied";
|
||||||
|
personalization_storage?: "granted" | "denied";
|
||||||
|
security_storage?: "granted" | "denied";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentConfig {
|
||||||
|
storageKey?: string;
|
||||||
|
waitForUpdateMs?: number;
|
||||||
|
adsDataRedaction?: boolean;
|
||||||
|
urlPassthrough?: boolean;
|
||||||
|
regionDefaults?: RegionDefault[];
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_STORAGE_KEY: string;
|
||||||
|
export const EVENT_CONSENT_UPDATED: string;
|
||||||
|
export const EVENT_OPEN_PREFERENCES: string;
|
||||||
|
export const GTM_CONSENT_EVENT: string;
|
||||||
|
export const GOOGLE_CONSENT_SIGNALS: readonly string[];
|
||||||
|
|
||||||
|
export function configureConsent(config?: ConsentConfig): ConsentConfig;
|
||||||
|
export function getConsentConfig(): ConsentConfig;
|
||||||
|
export function defaultConfig: ConsentConfig;
|
||||||
|
|
||||||
|
export function getConsent(): UserConsent | null;
|
||||||
|
export function setConsent(preferences: Partial<UserConsent>): void;
|
||||||
|
export function acceptAll(): void;
|
||||||
|
export function rejectAll(): void;
|
||||||
|
export function hasAnalyticsConsent(): boolean;
|
||||||
|
export function hasMarketingConsent(): boolean;
|
||||||
|
export function hasConsentChoice(): boolean;
|
||||||
|
export function syncConsentFromStorage(): void;
|
||||||
|
export function openPreferences(): void;
|
||||||
|
|
||||||
|
export function applyConsentDefaults(): void;
|
||||||
|
export function applyConsentUpdate(consent: UserConsent | null | undefined): void;
|
||||||
|
export function buildGoogleConsentState(consent: UserConsent | null | undefined): Record<string, "granted" | "denied">;
|
||||||
|
export function getConsentBootstrapScript(options?: Partial<ConsentConfig>): string;
|
||||||
|
|
||||||
|
// Legacy aliases
|
||||||
|
export const COOKIE_CONSENT_KEY: string;
|
||||||
|
export const COOKIE_CONSENT_EVENT: string;
|
||||||
|
export const COOKIE_CONSENT_OPEN_EVENT: string;
|
||||||
|
export const CONSENT_TYPES: readonly string[];
|
||||||
|
export function getCookieConsent(): UserConsent | null;
|
||||||
|
export function setCookieConsent(preferences: Partial<UserConsent>): void;
|
||||||
|
export function acceptAllCookies(): void;
|
||||||
|
export function rejectAllCookies(): void;
|
||||||
|
export function hasCookieConsentChoice(): boolean;
|
||||||
|
export function syncGoogleConsentFromStorage(): void;
|
||||||
|
export function openCookiePreferences(): void;
|
||||||
|
export function applyGoogleConsentDefault(): void;
|
||||||
|
export function applyGoogleConsent(consent: UserConsent | null | undefined): void;
|
||||||
46
src/index.js
Normal file
46
src/index.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Configuration
|
||||||
|
export { configureConsent, getConsentConfig, defaultConfig } from "./config.js";
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export {
|
||||||
|
DEFAULT_STORAGE_KEY,
|
||||||
|
EVENT_CONSENT_UPDATED,
|
||||||
|
EVENT_OPEN_PREFERENCES,
|
||||||
|
GTM_CONSENT_EVENT,
|
||||||
|
GOOGLE_CONSENT_SIGNALS,
|
||||||
|
// legacy names
|
||||||
|
DEFAULT_STORAGE_KEY as COOKIE_CONSENT_KEY,
|
||||||
|
EVENT_CONSENT_UPDATED as COOKIE_CONSENT_EVENT,
|
||||||
|
EVENT_OPEN_PREFERENCES as COOKIE_CONSENT_OPEN_EVENT,
|
||||||
|
GOOGLE_CONSENT_SIGNALS as CONSENT_TYPES,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
// User API
|
||||||
|
export {
|
||||||
|
getConsent,
|
||||||
|
setConsent,
|
||||||
|
acceptAll,
|
||||||
|
rejectAll,
|
||||||
|
hasAnalyticsConsent,
|
||||||
|
hasMarketingConsent,
|
||||||
|
hasConsentChoice,
|
||||||
|
syncConsentFromStorage,
|
||||||
|
openPreferences,
|
||||||
|
// legacy aliases
|
||||||
|
getCookieConsent,
|
||||||
|
setCookieConsent,
|
||||||
|
acceptAllCookies,
|
||||||
|
rejectAllCookies,
|
||||||
|
hasCookieConsentChoice,
|
||||||
|
syncGoogleConsentFromStorage,
|
||||||
|
openCookiePreferences,
|
||||||
|
} from "./consent-api.js";
|
||||||
|
|
||||||
|
// Google Consent Mode
|
||||||
|
export { applyConsentDefaults, applyConsentUpdate } from "./google/consent-lifecycle.js";
|
||||||
|
export { buildConsentSignals as buildGoogleConsentState } from "./google/consent-signals.js";
|
||||||
|
export { getConsentBootstrapScript } from "./google/bootstrap-script.js";
|
||||||
|
|
||||||
|
// Legacy Google aliases
|
||||||
|
export { applyConsentDefaults as applyGoogleConsentDefault } from "./google/consent-lifecycle.js";
|
||||||
|
export { applyConsentUpdate as applyGoogleConsent } from "./google/consent-lifecycle.js";
|
||||||
37
src/preferences.js
Normal file
37
src/preferences.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* User-facing consent categories.
|
||||||
|
* @typedef {{ analytics: boolean, marketing: boolean }} UserConsent
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise legacy or partial values into a consistent shape.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {UserConsent|null}
|
||||||
|
*/
|
||||||
|
export function parseUserConsent(value) {
|
||||||
|
if (value === "accepted") {
|
||||||
|
return { analytics: true, marketing: true };
|
||||||
|
}
|
||||||
|
if (value === "rejected") {
|
||||||
|
return { analytics: false, marketing: false };
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return {
|
||||||
|
analytics: Boolean(value.analytics),
|
||||||
|
marketing: Boolean(value.marketing),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise consent for localStorage.
|
||||||
|
* @param {UserConsent} consent
|
||||||
|
*/
|
||||||
|
export function serializeUserConsent(consent) {
|
||||||
|
return JSON.stringify({
|
||||||
|
...consent,
|
||||||
|
version: 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
18
src/react/ConsentBootstrap.jsx
Normal file
18
src/react/ConsentBootstrap.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import Script from "next/script";
|
||||||
|
import { getConsentBootstrapScript } from "../google/bootstrap-script.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects Google Consent Mode v2 defaults before any Google tag loads.
|
||||||
|
* Place in root layout `<body>`, above GTM.
|
||||||
|
*/
|
||||||
|
export default function ConsentBootstrap({ waitForUpdateMs } = {}) {
|
||||||
|
const script = getConsentBootstrapScript(
|
||||||
|
waitForUpdateMs !== undefined ? { waitForUpdateMs } : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Script id="cookie-consent-bootstrap" strategy="beforeInteractive">
|
||||||
|
{script}
|
||||||
|
</Script>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/react/CookieConsent.jsx
Normal file
225
src/react/CookieConsent.jsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { EVENT_OPEN_PREFERENCES } from "../constants.js";
|
||||||
|
import {
|
||||||
|
acceptAll,
|
||||||
|
getConsent,
|
||||||
|
hasConsentChoice,
|
||||||
|
rejectAll,
|
||||||
|
setConsent,
|
||||||
|
} from "../consent-api.js";
|
||||||
|
|
||||||
|
const DEFAULT_LABELS = {
|
||||||
|
title: "Cookie preferences",
|
||||||
|
message: "We use cookies to improve your experience and analyze site traffic.",
|
||||||
|
policyLink: "Privacy policy",
|
||||||
|
accept: "Accept all",
|
||||||
|
reject: "Reject all",
|
||||||
|
manage: "Manage preferences",
|
||||||
|
save: "Save preferences",
|
||||||
|
necessaryTitle: "Necessary",
|
||||||
|
necessaryDescription: "Required for core site functionality. Always active.",
|
||||||
|
analyticsTitle: "Analytics",
|
||||||
|
analyticsDescription: "Helps us understand how visitors use the site (Google Analytics).",
|
||||||
|
marketingTitle: "Marketing",
|
||||||
|
marketingDescription: "Used for ads and remarketing (Google Ads, Meta Pixel).",
|
||||||
|
alwaysOn: "Always on",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie consent banner with granular preferences.
|
||||||
|
* Implements the same user flow as Google-certified CMPs.
|
||||||
|
*/
|
||||||
|
export default function CookieConsent({
|
||||||
|
labels: labelOverrides,
|
||||||
|
policyHref = "/privacy",
|
||||||
|
LinkComponent = Link,
|
||||||
|
}) {
|
||||||
|
const labels = { ...DEFAULT_LABELS, ...labelOverrides };
|
||||||
|
const PolicyLink = LinkComponent;
|
||||||
|
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [mode, setMode] = useState("banner");
|
||||||
|
const [analytics, setAnalytics] = useState(false);
|
||||||
|
const [marketing, setMarketing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(!hasConsentChoice());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOpenPreferences() {
|
||||||
|
const current = getConsent();
|
||||||
|
setAnalytics(current?.analytics ?? false);
|
||||||
|
setMarketing(current?.marketing ?? false);
|
||||||
|
setMode("preferences");
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(EVENT_OPEN_PREFERENCES, handleOpenPreferences);
|
||||||
|
return () => window.removeEventListener(EVENT_OPEN_PREFERENCES, handleOpenPreferences);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAcceptAll() {
|
||||||
|
acceptAll();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRejectAll() {
|
||||||
|
rejectAll();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
setConsent({ analytics, marketing });
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="ccm-banner"
|
||||||
|
role="dialog"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={labels.title}
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div className="ccm-banner__inner">
|
||||||
|
{mode === "banner" ? (
|
||||||
|
<BannerView
|
||||||
|
labels={labels}
|
||||||
|
PolicyLink={PolicyLink}
|
||||||
|
policyHref={policyHref}
|
||||||
|
onAccept={handleAcceptAll}
|
||||||
|
onReject={handleRejectAll}
|
||||||
|
onManage={() => setMode("preferences")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PreferencesView
|
||||||
|
labels={labels}
|
||||||
|
PolicyLink={PolicyLink}
|
||||||
|
policyHref={policyHref}
|
||||||
|
analytics={analytics}
|
||||||
|
marketing={marketing}
|
||||||
|
onAnalyticsChange={setAnalytics}
|
||||||
|
onMarketingChange={setMarketing}
|
||||||
|
onSave={handleSave}
|
||||||
|
onAcceptAll={handleAcceptAll}
|
||||||
|
onRejectAll={handleRejectAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BannerView({ labels, PolicyLink, policyHref, onAccept, onReject, onManage }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="ccm-banner__text">
|
||||||
|
{labels.message}{" "}
|
||||||
|
<PolicyLink href={policyHref}>{labels.policyLink}</PolicyLink>
|
||||||
|
</p>
|
||||||
|
<div className="ccm-banner__actions">
|
||||||
|
<button type="button" className="ccm-banner__btn ccm-banner__btn--primary" onClick={onAccept}>
|
||||||
|
{labels.accept}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ccm-banner__btn ccm-banner__btn--secondary" onClick={onManage}>
|
||||||
|
{labels.manage}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ccm-banner__btn ccm-banner__btn--ghost" onClick={onReject}>
|
||||||
|
{labels.reject}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreferencesView({
|
||||||
|
labels,
|
||||||
|
PolicyLink,
|
||||||
|
policyHref,
|
||||||
|
analytics,
|
||||||
|
marketing,
|
||||||
|
onAnalyticsChange,
|
||||||
|
onMarketingChange,
|
||||||
|
onSave,
|
||||||
|
onAcceptAll,
|
||||||
|
onRejectAll,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="ccm-banner__header">
|
||||||
|
<h2 className="ccm-banner__title">{labels.title}</h2>
|
||||||
|
<p className="ccm-banner__text">
|
||||||
|
{labels.message}{" "}
|
||||||
|
<PolicyLink href={policyHref}>{labels.policyLink}</PolicyLink>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ccm-banner__categories">
|
||||||
|
<CategoryCard
|
||||||
|
title={labels.necessaryTitle}
|
||||||
|
description={labels.necessaryDescription}
|
||||||
|
locked
|
||||||
|
badge={labels.alwaysOn}
|
||||||
|
/>
|
||||||
|
<CategoryCard
|
||||||
|
title={labels.analyticsTitle}
|
||||||
|
description={labels.analyticsDescription}
|
||||||
|
checked={analytics}
|
||||||
|
onChange={onAnalyticsChange}
|
||||||
|
/>
|
||||||
|
<CategoryCard
|
||||||
|
title={labels.marketingTitle}
|
||||||
|
description={labels.marketingDescription}
|
||||||
|
checked={marketing}
|
||||||
|
onChange={onMarketingChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ccm-banner__actions">
|
||||||
|
<button type="button" className="ccm-banner__btn ccm-banner__btn--primary" onClick={onSave}>
|
||||||
|
{labels.save}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ccm-banner__btn ccm-banner__btn--primary" onClick={onAcceptAll}>
|
||||||
|
{labels.accept}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ccm-banner__btn ccm-banner__btn--ghost" onClick={onRejectAll}>
|
||||||
|
{labels.reject}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryCard({ title, description, checked, onChange, locked, badge }) {
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div className="ccm-banner__category ccm-banner__category--locked">
|
||||||
|
<div className="ccm-banner__category-head">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span className="ccm-banner__badge">{badge}</span>
|
||||||
|
</div>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="ccm-banner__category">
|
||||||
|
<div className="ccm-banner__category-head">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
</div>
|
||||||
|
<p>{description}</p>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/react/FacebookPixel.jsx
Normal file
55
src/react/FacebookPixel.jsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import { EVENT_CONSENT_UPDATED } from "../constants.js";
|
||||||
|
import { hasMarketingConsent } from "../consent-api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads Meta Pixel only after marketing consent is granted.
|
||||||
|
*/
|
||||||
|
export default function FacebookPixel({ pixelId = "" }) {
|
||||||
|
const id = String(pixelId || "").trim();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEnabled(hasMarketingConsent());
|
||||||
|
|
||||||
|
function onConsentChange() {
|
||||||
|
setEnabled(hasMarketingConsent());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(EVENT_CONSENT_UPDATED, onConsentChange);
|
||||||
|
return () => window.removeEventListener(EVENT_CONSENT_UPDATED, onConsentChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!enabled || !id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script id="cookie-consent-fb-pixel" strategy="lazyOnload">
|
||||||
|
{`
|
||||||
|
!function(f,b,e,v,n,t,s)
|
||||||
|
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||||
|
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||||
|
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||||
|
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||||
|
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||||
|
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||||
|
'https://connect.facebook.net/en_US/fbevents.js');
|
||||||
|
fbq('init', '${id}');
|
||||||
|
fbq('track', 'PageView');
|
||||||
|
`}
|
||||||
|
</Script>
|
||||||
|
<noscript>
|
||||||
|
<img
|
||||||
|
height="1"
|
||||||
|
width="1"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
src={`https://www.facebook.com/tr?id=${id}&ev=PageView&noscript=1`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</noscript>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/react/GoogleTagManager.jsx
Normal file
42
src/react/GoogleTagManager.jsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import { syncConsentFromStorage } from "../consent-api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads Google Tag Manager after consent defaults are set.
|
||||||
|
* Only injects when `gtmId` is provided.
|
||||||
|
*/
|
||||||
|
export default function GoogleTagManager({ gtmId = "" }) {
|
||||||
|
const id = String(gtmId || "").trim();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncConsentFromStorage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script id="cookie-consent-gtm" strategy="afterInteractive">
|
||||||
|
{`
|
||||||
|
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||||
|
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||||
|
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||||
|
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||||
|
})(window,document,'script','dataLayer','${id}');
|
||||||
|
`}
|
||||||
|
</Script>
|
||||||
|
<noscript>
|
||||||
|
<iframe
|
||||||
|
src={`https://www.googletagmanager.com/ns.html?id=${id}`}
|
||||||
|
height="0"
|
||||||
|
width="0"
|
||||||
|
style={{ display: "none", visibility: "hidden" }}
|
||||||
|
title="Google Tag Manager"
|
||||||
|
/>
|
||||||
|
</noscript>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/react/index.js
Normal file
23
src/react/index.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export { default as CookieConsent } from "./CookieConsent.jsx";
|
||||||
|
export { default as ConsentBootstrap } from "./ConsentBootstrap.jsx";
|
||||||
|
export { default as GoogleTagManager } from "./GoogleTagManager.jsx";
|
||||||
|
export { default as FacebookPixel } from "./FacebookPixel.jsx";
|
||||||
|
|
||||||
|
export { configureConsent } from "../config.js";
|
||||||
|
export {
|
||||||
|
getConsent,
|
||||||
|
setConsent,
|
||||||
|
acceptAll,
|
||||||
|
rejectAll,
|
||||||
|
hasAnalyticsConsent,
|
||||||
|
hasMarketingConsent,
|
||||||
|
openPreferences,
|
||||||
|
syncConsentFromStorage,
|
||||||
|
// legacy
|
||||||
|
getCookieConsent,
|
||||||
|
setCookieConsent,
|
||||||
|
acceptAllCookies,
|
||||||
|
rejectAllCookies,
|
||||||
|
openCookiePreferences,
|
||||||
|
syncGoogleConsentFromStorage,
|
||||||
|
} from "../consent-api.js";
|
||||||
30
src/storage.js
Normal file
30
src/storage.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { getStorageKey } from "./config.js";
|
||||||
|
import { parseUserConsent, serializeUserConsent } from "./preferences.js";
|
||||||
|
|
||||||
|
export function readStoredConsent() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(getStorageKey());
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseUserConsent(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return parseUserConsent(raw);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredConsent(consent) {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(getStorageKey(), serializeUserConsent(consent));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/styles.css
Normal file
164
src/styles.css
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/* Cookie Consent Module — banner styles */
|
||||||
|
|
||||||
|
.ccm-banner {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10050;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(5, 22, 52, 0.96);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__header {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__text {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 320px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__text a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__categories {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__category {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__category--locked {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__category p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__category-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__category-head strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__category-head input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #4a90d9;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn--primary {
|
||||||
|
background: #4a90d9;
|
||||||
|
border: 1px solid #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn--primary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn--secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn--secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn--ghost:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ccm-banner__actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccm-banner__btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user