first commit

This commit is contained in:
Pdn Technology 2026-06-20 08:47:21 +03:00
commit 48cabcf293
22 changed files with 1411 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
.DS_Store
*.log
.env
dist/
coverage/

4
.npmignore Normal file
View File

@ -0,0 +1,4 @@
.git
.gitignore
node_modules
*.log

21
LICENSE Normal file
View 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
View File

@ -0,0 +1,325 @@
# @pdntechnology/cookie-consent
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![npm version](https://img.shields.io/npm/v/@pdntechnology/cookie-consent.svg)](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
View 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
View 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
View 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
View 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
View 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();
}

View 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 });
}

View 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
View 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
View 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
View 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
View 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(),
});
}

View 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
View 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>
);
}

View 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>
</>
);
}

View 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
View 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
View 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
View 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;
}
}