commit 48cabcf293e077d769c33ed947d64d4afa213728 Author: Pdn Technology Date: Sat Jun 20 08:47:21 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22de42a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +*.log +.env +dist/ +coverage/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f302d28 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +.git +.gitignore +node_modules +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..48dea85 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f12d854 --- /dev/null +++ b/README.md @@ -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 ( + + + + {children} + + + ); +} +``` + +### 3. Banner + tags + +```jsx +// app/providers.jsx +import { + CookieConsent, + GoogleTagManager, + FacebookPixel, +} from "@pdntechnology/cookie-consent/react"; + +export function Providers({ children }) { + return ( + <> + {children} + + + + + ); +} +``` + +### 4. Re-open preferences (footer link) + +```jsx +import { openPreferences } from "@pdntechnology/cookie-consent"; + + +``` + +--- + +## 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 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 + + + + +``` + +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) diff --git a/package.json b/package.json new file mode 100644 index 0000000..aac0522 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..ad5674b --- /dev/null +++ b/src/config.js @@ -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 }; diff --git a/src/consent-api.js b/src/consent-api.js new file mode 100644 index 0000000..96ed011 --- /dev/null +++ b/src/consent-api.js @@ -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; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..1a0e653 --- /dev/null +++ b/src/constants.js @@ -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", +}; diff --git a/src/google/bootstrap-script.js b/src/google/bootstrap-script.js new file mode 100644 index 0000000..af0ff00 --- /dev/null +++ b/src/google/bootstrap-script.js @@ -0,0 +1,29 @@ +import { getConsentConfig } from "../config.js"; +import { getDefaultDeniedState } from "./consent-signals.js"; + +/** + * Inline script for `` — 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(); +} diff --git a/src/google/consent-lifecycle.js b/src/google/consent-lifecycle.js new file mode 100644 index 0000000..f3498da --- /dev/null +++ b/src/google/consent-lifecycle.js @@ -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 }); +} diff --git a/src/google/consent-signals.js b/src/google/consent-signals.js new file mode 100644 index 0000000..294a1fb --- /dev/null +++ b/src/google/consent-signals.js @@ -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, + }; +} diff --git a/src/google/gtag.js b/src/google/gtag.js new file mode 100644 index 0000000..aa538f6 --- /dev/null +++ b/src/google/gtag.js @@ -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); +} diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..90ebc29 --- /dev/null +++ b/src/index.d.ts @@ -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): 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; +export function getConsentBootstrapScript(options?: Partial): 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): 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; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..6cb4ccd --- /dev/null +++ b/src/index.js @@ -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"; diff --git a/src/preferences.js b/src/preferences.js new file mode 100644 index 0000000..d11c470 --- /dev/null +++ b/src/preferences.js @@ -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(), + }); +} diff --git a/src/react/ConsentBootstrap.jsx b/src/react/ConsentBootstrap.jsx new file mode 100644 index 0000000..962d1d3 --- /dev/null +++ b/src/react/ConsentBootstrap.jsx @@ -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 ``, above GTM. + */ +export default function ConsentBootstrap({ waitForUpdateMs } = {}) { + const script = getConsentBootstrapScript( + waitForUpdateMs !== undefined ? { waitForUpdateMs } : undefined + ); + + return ( + + ); +} diff --git a/src/react/CookieConsent.jsx b/src/react/CookieConsent.jsx new file mode 100644 index 0000000..a7659dd --- /dev/null +++ b/src/react/CookieConsent.jsx @@ -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 ( +
+
+ {mode === "banner" ? ( + setMode("preferences")} + /> + ) : ( + + )} +
+
+ ); +} + +function BannerView({ labels, PolicyLink, policyHref, onAccept, onReject, onManage }) { + return ( + <> +

+ {labels.message}{" "} + {labels.policyLink} +

+
+ + + +
+ + ); +} + +function PreferencesView({ + labels, + PolicyLink, + policyHref, + analytics, + marketing, + onAnalyticsChange, + onMarketingChange, + onSave, + onAcceptAll, + onRejectAll, +}) { + return ( + <> +
+

{labels.title}

+

+ {labels.message}{" "} + {labels.policyLink} +

+
+ +
+ + + +
+ +
+ + + +
+ + ); +} + +function CategoryCard({ title, description, checked, onChange, locked, badge }) { + if (locked) { + return ( +
+
+ {title} + {badge} +
+

{description}

+
+ ); + } + + return ( + + ); +} diff --git a/src/react/FacebookPixel.jsx b/src/react/FacebookPixel.jsx new file mode 100644 index 0000000..85f0219 --- /dev/null +++ b/src/react/FacebookPixel.jsx @@ -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 ( + <> + + + + ); +} diff --git a/src/react/GoogleTagManager.jsx b/src/react/GoogleTagManager.jsx new file mode 100644 index 0000000..7336f06 --- /dev/null +++ b/src/react/GoogleTagManager.jsx @@ -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 ( + <> + +