Tema ayarları eklendi
Some checks are pending
CI / test (20) (push) Waiting to run
CI / test (22) (push) Waiting to run

This commit is contained in:
Pdn Technology 2026-06-20 08:58:11 +03:00
parent 48cabcf293
commit 9a001197e1
37 changed files with 4265 additions and 57 deletions

29
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ node_modules/
.env
dist/
coverage/
.next/

40
CHANGELOG.md Normal file
View File

@ -0,0 +1,40 @@
# Changelog
All notable changes to this project are documented in this file.
## [1.1.0] - 2026-06-20
### Added
- `theme` prop on `CookieConsent` for brand color customization via CSS variables
- `className` prop on `CookieConsent`
- Optional `cookies` prop with expandable cookie inventory tables per category
- New label keys for cookie table headers and storage error messages
- `onConsentSaved` and `onConsentError` callbacks on `CookieConsent`
- `PersistResult` return values from `setConsent`, `acceptAll`, and `rejectAll`
- `configureConsent({ onStorageError })` global error callback
- `debug: true` console warnings for invalid or failed consent writes
- `ads_data_redaction` and `url_passthrough` in bootstrap default script
- Multi-tab consent sync via the `storage` event
- Keyboard support: `Escape` closes preferences or returns to banner
- Focus management when the banner opens
- TypeScript definitions for `@pdntechnology/cookie-consent/react`
- Cookie schema helpers exported from the main package
- Vitest test suite and GitHub Actions CI
- Next.js demo app in `examples/next-app`
- Turkish documentation in `README.tr.md`
- `CONTRIBUTING.md`
### Changed
- Banner styles now use CSS custom properties instead of hardcoded colors
- Bootstrap script includes advanced Consent Mode settings by default
## [1.0.0] - 2026-06-20
### Added
- Initial release with Google Consent Mode v2 integration
- React banner and Next.js bootstrap components
- Optional GTM and Meta Pixel loaders
- Framework-agnostic consent API

72
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,72 @@
# Contributing
Thank you for considering a contribution to `@pdntechnology/cookie-consent`.
## Before you start
- Open an issue for large changes so we can agree on the approach first.
- Keep pull requests focused. One feature or fix per PR is easier to review.
- Match the existing code style: small modules, clear names, minimal dependencies.
## Development setup
```bash
git clone https://git.pdntechnology.com.tr/pdn/cookie-consent.git
cd cookie-consent
npm install
npm test
```
### Try the Next.js demo
```bash
cd examples/next-app
npm install
npm run dev
```
The example uses a local file dependency on the parent package.
## Tests
All changes should pass the test suite:
```bash
npm test
```
Add or update tests when you change behaviour in:
- `src/preferences.js`
- `src/storage.js`
- `src/consent-api.js`
- `src/google/*`
- `src/cookie-schema.js`
- `src/react/theme.js`
## Pull request checklist
- [ ] `npm test` passes locally
- [ ] Public API changes are reflected in `src/index.d.ts` and/or `src/react/index.d.ts`
- [ ] User-facing changes are documented in `README.md`
- [ ] Turkish docs updated in `README.tr.md` when behaviour or public API changes
- [ ] `CHANGELOG.md` updated under `[Unreleased]` or the next version
## Commit messages
Use clear, imperative messages:
- `Add theme prop to CookieConsent`
- `Fix bootstrap script advanced settings`
- `Document cookie inventory API`
## Code guidelines
- Prefer framework-agnostic logic in `src/` and React-specific UI in `src/react/`
- Do not add runtime dependencies without a strong reason
- Fail safely: never throw from public consent APIs in the browser
- Surface storage failures via return values, callbacks, or debug logging
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).

187
README.md
View File

@ -29,12 +29,16 @@ This library implements that flow in ~15 KB of readable JavaScript.
- Google Consent Mode v2 (all 7 signals)
- Granular categories: Necessary / Analytics / Marketing
- `ads_data_redaction` + `url_passthrough` when marketing is denied
- Customizable banner text, theme colors, and cookie inventory
- `ads_data_redaction` + `url_passthrough` in bootstrap and on update
- 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
- TypeScript definitions for core API and React components
- Error callbacks when consent cannot be saved
> Turkish documentation: [README.tr.md](README.tr.md)
---
@ -81,7 +85,7 @@ Import this file in your root layout **before** `ConsentBootstrap`.
// app/layout.jsx
import "@pdntechnology/cookie-consent/styles.css";
import "@/lib/consent-config";
import ConsentBootstrap from "@pdntechnology/cookie-consent/react/ConsentBootstrap";
import { ConsentBootstrap } from "@pdntechnology/cookie-consent/react";
export default function RootLayout({ children }) {
return (
@ -136,7 +140,142 @@ import { openPreferences } from "@pdntechnology/cookie-consent";
---
## How categories map to Google signals
## Customization
### Text and policy URL
Every visible string can be overridden via the `labels` prop. The policy link uses `policyHref`.
```jsx
<CookieConsent
policyHref="/privacy"
labels={{
title: "Cookie preferences",
message: "We use cookies to improve your experience.",
policyLink: "Privacy policy",
accept: "Accept all",
reject: "Reject all",
manage: "Manage preferences",
save: "Save preferences",
necessaryTitle: "Necessary",
necessaryDescription: "Required for core site functionality.",
analyticsTitle: "Analytics",
analyticsDescription: "Google Analytics (_ga, _gid).",
marketingTitle: "Marketing",
marketingDescription: "Google Ads and Meta Pixel.",
alwaysOn: "Always on",
storageError: "Your preferences could not be saved. Please try again.",
}}
/>
```
| Label key | Default |
|-----------|---------|
| `title` | Cookie preferences |
| `message` | We use cookies to improve your experience… |
| `policyLink` | Privacy policy |
| `accept` | Accept all |
| `reject` | Reject all |
| `manage` | Manage preferences |
| `save` | Save preferences |
| `necessaryTitle` | Necessary |
| `necessaryDescription` | Required for core site functionality… |
| `analyticsTitle` | Analytics |
| `analyticsDescription` | Helps us understand how visitors use the site… |
| `marketingTitle` | Marketing |
| `marketingDescription` | Used for ads and remarketing… |
| `alwaysOn` | Always on |
| `cookiesHeading` | View cookies |
| `cookieName` | Name |
| `cookiePurpose` | Purpose |
| `cookieDuration` | Duration |
| `cookieProvider` | Provider |
| `storageError` | Your preferences could not be saved… |
### Theme colors
Pass a `theme` object to override CSS variables without writing custom CSS:
```jsx
<CookieConsent
theme={{
background: "rgba(15, 23, 42, 0.97)",
text: "#f8fafc",
textMuted: "rgba(248, 250, 252, 0.82)",
link: "#93c5fd",
primary: "#2563eb",
primaryText: "#ffffff",
secondaryBackground: "rgba(255, 255, 255, 0.1)",
border: "rgba(255, 255, 255, 0.14)",
accent: "#2563eb",
}}
/>
```
You can also pass `className` for additional styling hooks.
### Cookie inventory table
Provide a `cookies` array to show an expandable table inside each category in the preferences panel:
```jsx
<CookieConsent
cookies={[
{
category: "necessary",
name: "my_site_consent",
purpose: "Stores your consent choice",
duration: "1 year",
provider: "This site",
},
{
category: "analytics",
name: "_ga",
purpose: "Visitor statistics",
duration: "2 years",
provider: "Google Analytics",
},
]}
/>
```
If `cookies` is omitted, the banner keeps the simpler three-category view.
### Error handling
Consent write functions return a result object:
```js
const result = acceptAll();
if (!result.ok) {
console.error(result.reason); // "invalid" | "storage"
}
```
In React, use callbacks on the banner:
```jsx
<CookieConsent
onConsentSaved={(consent) => console.log("saved", consent)}
onConsentError={({ reason }) => console.warn("failed", reason)}
/>
```
Enable `debug: true` in `configureConsent()` to log warnings to the console, or use `onStorageError` for global handling.
---
## Example app
See [`examples/next-app`](examples/next-app) for a working Next.js demo with Turkish labels, custom theme colors, and a cookie inventory table.
```bash
cd examples/next-app
npm install
npm run dev
```
---
| Banner category | User choice | Google Consent Mode signals |
|-----------------|-------------|----------------------------|
@ -161,6 +300,7 @@ src/
├── preferences.js Parse / serialize user choices
├── storage.js localStorage read/write
├── consent-api.js acceptAll, rejectAll, getConsent, …
├── cookie-schema.js Cookie inventory helpers
├── google/
│ ├── gtag.js dataLayer helpers
│ ├── consent-signals.js Category → signal mapping
@ -169,6 +309,8 @@ src/
└── react/
├── ConsentBootstrap.jsx
├── CookieConsent.jsx
├── CookieList.jsx
├── theme.js
├── GoogleTagManager.jsx
└── FacebookPixel.jsx
```
@ -189,9 +331,9 @@ src/
| Function | Description |
|----------|-------------|
| `getConsent()` | Read stored preferences |
| `setConsent({ analytics, marketing })` | Save custom choice |
| `acceptAll()` | Grant all categories |
| `rejectAll()` | Deny optional categories |
| `setConsent({ analytics, marketing })` | Save custom choice; returns `{ ok, consent \| reason }` |
| `acceptAll()` | Grant all categories; returns `{ ok, consent \| reason }` |
| `rejectAll()` | Deny optional categories; returns `{ ok, consent \| reason }` |
| `hasAnalyticsConsent()` | Check analytics opt-in |
| `hasMarketingConsent()` | Check marketing opt-in |
| `openPreferences()` | Re-open banner panel |
@ -214,7 +356,18 @@ src/
| `EVENT_OPEN_PREFERENCES` | Preferences panel should open |
| `GTM_CONSENT_EVENT` | Pushed to dataLayer (`cookie_consent_update`) |
Legacy aliases (`getCookieConsent`, `acceptAllCookies`, …) remain available.
### `CookieConsent` props
| Prop | Type | Description |
|------|------|-------------|
| `labels` | `object` | Override banner text |
| `theme` | `object` | Override banner colors |
| `cookies` | `CookieEntry[]` | Optional cookie inventory table |
| `policyHref` | `string` | Privacy / cookie policy URL |
| `LinkComponent` | `component` | Custom link component (default: Next.js `Link`) |
| `className` | `string` | Extra CSS class on the banner root |
| `onConsentSaved` | `(consent) => void` | Called after a successful save |
| `onConsentError` | `({ reason }) => void` | Called when save fails |
---
@ -278,7 +431,17 @@ configureConsent({
---
## Local development (link to another project)
## Troubleshooting
| Problem | Likely cause | Fix |
|---------|--------------|-----|
| Banner never appears | Consent already stored | Clear `localStorage` key or call `openPreferences()` |
| GTM fires before consent | Bootstrap missing or loaded too late | Add `<ConsentBootstrap />` with `beforeInteractive` before GTM |
| Consent not saved | Storage blocked (private mode, Safari ITP) | Handle `onConsentError`; show inline error from banner |
| Tags still fire after reject | GTM tags not using Consent Mode | Enable Consent Mode in GTM and set tag consent requirements |
| TypeScript errors on `./react` | Missing types path | Import from `@pdntechnology/cookie-consent/react` (types included since v1.1) |
---
```bash
# In this repo
@ -312,11 +475,7 @@ 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
See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports and pull requests are welcome.
---

344
README.tr.md Normal file
View File

@ -0,0 +1,344 @@
# @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)
React ve Next.js için hafif, açık kaynak bir **Çerez Yönetim Platformu (CMP)**. [Google Consent Mode v2](https://developers.google.com/tag-platform/security/guides/consent) akışını sertifikalı araçlarla aynı şekilde uygular.
ır bağımlılık yok. Vendor lock-in yok. Google Tag Manager, GA4, Google Ads ve Meta Pixel ile uyumludur.
> English documentation: [README.md](README.md)
---
## Neden var?
Google sertifikalı CMP'ler (Cookiebot, OneTrust, CookieYes, …) aynı teknik akışı izler:
1. Google etiketlerinden **önce** varsayılanları **reddedilmiş** olarak ayarla
2. GTM / gtag.js yükle
3. Çerez banner'ını göster
4. Kullanıcı seçimi → `gtag('consent', 'update', …)` + dataLayer olayı
5. Reklamlar reddedildiğinde → `ads_data_redaction` ve `url_passthrough` etkinleştir
Bu kütüphane bu akışı okunabilir, yaklaşık 15 KB JavaScript ile uygular.
> **Not:** Sertifikalı CMP'ler ayrıca IAB TCF v2.2 sinyalleri sağlar ve Google'ın sertifikalı satıcı listesinde yer alır. Bu kütüphane **Consent Mode v2 teknik entegrasyonunu** kapsar. Gizlilik politikası metni ve yasal uyumluluk sizin sorumluluğunuzdadır.
---
## Özellikler
- Google Consent Mode v2 (7 sinyalin tamamı)
- Granüler kategoriler: Zorunlu / Analitik / Pazarlama
- Özelleştirilebilir banner metni, tema renkleri ve çerez envanteri
- Bootstrap ve güncellemede `ads_data_redaction` + `url_passthrough`
- Bölgesel varsayılan onay (EEA vb.)
- React banner + Next.js bootstrap bileşeni
- İsteğe bağlı GTM ve Meta Pixel yükleyicileri (onay kapılı)
- Framework'ten bağımsız çekirdek API (vanilla JS desteklenir)
- Çekirdek API ve React bileşenleri için TypeScript tanımları
- Onay kaydedilemediğinde hata callback'leri
---
## Kurulum
```bash
npm install @pdntechnology/cookie-consent
```
### Next.js ayarı
`next.config.mjs` dosyasına ekleyin:
```js
const nextConfig = {
transpilePackages: ["@pdntechnology/cookie-consent"],
};
```
---
## Hızlı başlangıç
### 1. Yapılandırma (isteğe bağlı)
```js
// lib/consent-config.js
import { configureConsent } from "@pdntechnology/cookie-consent";
configureConsent({
storageKey: "site_cerez_tercihi",
waitForUpdateMs: 500,
adsDataRedaction: true,
urlPassthrough: true,
debug: false,
});
```
Bu dosyayı root layout'ta `ConsentBootstrap`'ten **önce** import edin.
### 2. Consent varsayılanlarını bootstrap et (GTM'den önce)
```jsx
// app/layout.jsx
import "@pdntechnology/cookie-consent/styles.css";
import "@/lib/consent-config";
import { ConsentBootstrap } from "@pdntechnology/cookie-consent/react";
export default function RootLayout({ children }) {
return (
<html lang="tr">
<body>
<ConsentBootstrap />
{children}
</body>
</html>
);
}
```
### 3. Banner + etiketler
```jsx
// app/providers.jsx
import Link from "next/link";
import {
CookieConsent,
GoogleTagManager,
} from "@pdntechnology/cookie-consent/react";
export function Providers({ children }) {
return (
<>
{children}
<CookieConsent
policyHref="/cerez-politikasi"
LinkComponent={Link}
labels={{
title: "Çerez tercihleri",
message: "Deneyiminizi iyileştirmek için çerez kullanıyoruz.",
policyLink: "Çerez politikası",
accept: "Tümünü kabul et",
reject: "Tümünü reddet",
manage: "Tercihleri yönet",
}}
theme={{
primary: "#2563eb",
background: "rgba(15, 23, 42, 0.97)",
}}
/>
<GoogleTagManager gtmId="GTM-XXXXXXX" />
</>
);
}
```
### 4. Tercihleri yeniden aç (footer linki)
```jsx
import { openPreferences } from "@pdntechnology/cookie-consent";
<button type="button" onClick={openPreferences}>
Çerez ayarları
</button>
```
---
## Özelleştirme
### Metin ve politika URL'si
Görünen tüm metinler `labels` prop'u ile değiştirilebilir. Politika linki `policyHref` ile ayarlanır.
| Label anahtarı | Varsayılan (EN) |
|----------------|-----------------|
| `title` | Cookie preferences |
| `message` | We use cookies to improve your experience… |
| `policyLink` | Privacy policy |
| `accept` | Accept all |
| `reject` | Reject all |
| `manage` | Manage preferences |
| `save` | Save preferences |
| `necessaryTitle` | Necessary |
| `necessaryDescription` | Required for core site functionality… |
| `analyticsTitle` | Analytics |
| `analyticsDescription` | Helps us understand how visitors use the site… |
| `marketingTitle` | Marketing |
| `marketingDescription` | Used for ads and remarketing… |
| `alwaysOn` | Always on |
| `cookiesHeading` | View cookies |
| `cookieName` | Name |
| `cookiePurpose` | Purpose |
| `cookieDuration` | Duration |
| `cookieProvider` | Provider |
| `storageError` | Your preferences could not be saved… |
### Tema renkleri
`theme` prop'u ile CSS değişkenlerini override edin:
```jsx
<CookieConsent
theme={{
background: "rgba(15, 23, 42, 0.97)",
text: "#f8fafc",
link: "#93c5fd",
primary: "#2563eb",
accent: "#2563eb",
}}
/>
```
Desteklenen anahtarlar: `background`, `text`, `textMuted`, `link`, `primary`, `primaryText`, `secondaryBackground`, `border`, `accent`.
### Çerez envanteri tablosu
Hangi çerezlerin kullanıldığını tercihler panelinde göstermek için `cookies` dizisi verin:
```jsx
<CookieConsent
cookies={[
{
category: "necessary",
name: "site_cerez_tercihi",
purpose: "Çerez tercihlerinizi saklar",
duration: "1 yıl",
provider: "Bu site",
},
{
category: "analytics",
name: "_ga",
purpose: "Ziyaretçi istatistikleri",
duration: "2 yıl",
provider: "Google Analytics",
},
{
category: "marketing",
name: "_fbp",
purpose: "Reklam hedefleme",
duration: "3 ay",
provider: "Meta",
},
]}
labels={{
cookiesHeading: "Kullanılan çerezleri gör",
cookieName: "Ad",
cookiePurpose: "Amaç",
cookieDuration: "Süre",
cookieProvider: "Sağlayıcı",
}}
/>
```
`cookies` verilmezse banner mevcut üç kategori görünümünü korur.
### Hata yönetimi
```js
const result = acceptAll();
if (!result.ok) {
console.error(result.reason); // "invalid" | "storage"
}
```
React'te callback kullanın:
```jsx
<CookieConsent
onConsentSaved={(consent) => console.log("kaydedildi", consent)}
onConsentError={({ reason }) => console.warn("hata", reason)}
/>
```
`configureConsent({ debug: true })` ile konsola uyarı yazılır. Global işlem için `onStorageError` kullanılabilir.
---
## Örnek uygulama
[`examples/next-app`](examples/next-app) dizininde Türkçe metinler, özel tema ve çerez envanteri içeren çalışan bir Next.js demosu vardır.
```bash
cd examples/next-app
npm install
npm run dev
```
---
## Kategoriler → Google sinyalleri
| Banner kategorisi | Kullanıcı seçimi | Google Consent Mode sinyalleri |
|-------------------|------------------|--------------------------------|
| **Zorunlu** | Her zaman açık | `security_storage: granted` |
| **Analitik** | Opt-in | `analytics_storage` |
| **Pazarlama** | Opt-in | `ad_storage`, `ad_user_data`, `ad_personalization`, `functionality_storage`, `personalization_storage` |
Pazarlama **reddedildiğinde** kütüphane ayrıca şunları ayarlar:
- `ads_data_redaction: true`
- `url_passthrough: true`
---
## API referansı
### Yapılandırma
| Fonksiyon | Açıklama |
|-----------|----------|
| `configureConsent(config)` | Depolama anahtarı, bekleme süresi, bölgesel varsayılanlar |
| `getConsentConfig()` | Aktif yapılandırmayı oku |
### Kullanıcı onayı
| Fonksiyon | Açıklama |
|-----------|----------|
| `getConsent()` | Kayıtlı tercihleri oku |
| `setConsent({ analytics, marketing })` | Özel seçim kaydet; `{ ok, consent \| reason }` döner |
| `acceptAll()` | Tüm kategorilere izin ver |
| `rejectAll()` | Opsiyonel kategorileri reddet |
| `hasAnalyticsConsent()` | Analitik onayını kontrol et |
| `hasMarketingConsent()` | Pazarlama onayını kontrol et |
| `openPreferences()` | Tercih panelini yeniden aç |
| `syncConsentFromStorage()` | GTM yüklendikten sonra kayıtlı onayı uygula |
### `CookieConsent` prop'ları
| Prop | Tip | Açıklama |
|------|-----|----------|
| `labels` | `object` | Banner metinlerini override et |
| `theme` | `object` | Banner renklerini override et |
| `cookies` | `CookieEntry[]` | Opsiyonel çerez envanteri tablosu |
| `policyHref` | `string` | Gizlilik / çerez politikası URL'si |
| `LinkComponent` | `component` | Özel link bileşeni (varsayılan: Next.js `Link`) |
| `className` | `string` | Banner kök elemanına ek CSS sınıfı |
| `onConsentSaved` | `(consent) => void` | Başarılı kayıttan sonra çağrılır |
| `onConsentError` | `({ reason }) => void` | Kayıt başarısız olursa çağrılır |
---
## Sorun giderme
| Sorun | Olası neden | Çözüm |
|-------|-------------|-------|
| Banner hiç görünmüyor | Onay zaten kayıtlı | `localStorage` anahtarını temizleyin veya `openPreferences()` çağırın |
| GTM onaydan önce tetikleniyor | Bootstrap eksik veya geç yükleniyor | GTM'den önce `<ConsentBootstrap />` ekleyin |
| Onay kaydedilmiyor | Depolama engelli (gizli mod, Safari ITP) | `onConsentError` ile yakalayın; banner inline hata gösterir |
| Reddetmeden sonra etiketler çalışıyor | GTM etiketleri Consent Mode kullanmıyor | GTM'de Consent Mode'u etkinleştirin |
| `./react` TypeScript hatası | Eski sürüm | v1.1+ ile `@pdntechnology/cookie-consent/react` tipleri dahildir |
---
## Katkıda bulunma
Bkz. [CONTRIBUTING.md](CONTRIBUTING.md).
---
## Lisans
[MIT](LICENSE) © [PDN Technology](https://pdntechnology.com)

View File

@ -0,0 +1,15 @@
# Next.js Demo
Minimal demo for `@pdntechnology/cookie-consent`.
## Run locally
```bash
cd examples/next-app
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
The demo uses a local file dependency on the parent package, Turkish labels, a custom theme, and a cookie inventory table.

View File

@ -0,0 +1,20 @@
import "@pdntechnology/cookie-consent/styles.css";
import "@/lib/consent-config";
import { ConsentBootstrap } from "@pdntechnology/cookie-consent/react";
import { Providers } from "./providers";
export const metadata = {
title: "Cookie Consent Demo",
description: "Next.js demo for @pdntechnology/cookie-consent",
};
export default function RootLayout({ children }) {
return (
<html lang="tr">
<body>
<ConsentBootstrap />
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@ -0,0 +1,19 @@
import { CookieSettingsButton } from "./providers";
export default function HomePage() {
return (
<main style={{ maxWidth: 720, margin: "48px auto", padding: "0 24px", fontFamily: "system-ui, sans-serif" }}>
<h1>Cookie Consent Demo</h1>
<p>
Bu örnek uygulama, <code>@pdntechnology/cookie-consent</code> paketinin Next.js
ile nasıl kullanılacağını gösterir. Türkçe metinler, özel tema renkleri ve çerez
envanteri tablosu etkinleştirilmiştir.
</p>
<p>
Sayfayı ilk kez açtığınızda banner görünür. Tercih kaydedildikten sonra aşağıdaki
buton ile paneli yeniden açabilirsiniz.
</p>
<CookieSettingsButton />
</main>
);
}

View File

@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import {
CookieConsent,
openPreferences,
} from "@pdntechnology/cookie-consent/react";
const labels = {
title: "Çerez tercihleri",
message: "Deneyiminizi iyileştirmek ve site trafiğini analiz etmek için çerez kullanıyoruz.",
policyLink: "Çerez politikası",
accept: "Tümünü kabul et",
reject: "Tümünü reddet",
manage: "Tercihleri yönet",
save: "Tercihleri kaydet",
necessaryTitle: "Zorunlu",
necessaryDescription: "Sitenin temel işlevleri için gereklidir. Her zaman aktiftir.",
analyticsTitle: "Analitik",
analyticsDescription: "Google Analytics (_ga, _gid) ile ziyaretçi trafiğini ölçer.",
marketingTitle: "Pazarlama",
marketingDescription: "Google Ads ve Meta Pixel ile reklam performansını ölçer.",
alwaysOn: "Her zaman açık",
cookiesHeading: "Kullanılan çerezleri gör",
cookieName: "Ad",
cookiePurpose: "Amaç",
cookieDuration: "Süre",
cookieProvider: "Sağlayıcı",
storageError: "Tercihleriniz kaydedilemedi. Lütfen tekrar deneyin.",
};
const cookies = [
{
category: "necessary",
name: "demo_cookie_consent",
purpose: "Çerez tercihlerinizi saklar",
duration: "1 yıl",
provider: "Bu site",
},
{
category: "analytics",
name: "_ga",
purpose: "Ziyaretçi istatistikleri",
duration: "2 yıl",
provider: "Google Analytics",
},
{
category: "analytics",
name: "_gid",
purpose: "Oturum ayırma",
duration: "24 saat",
provider: "Google Analytics",
},
{
category: "marketing",
name: "_fbp",
purpose: "Reklam hedefleme",
duration: "3 ay",
provider: "Meta",
},
];
export function Providers({ children }) {
return (
<>
{children}
<CookieConsent
policyHref="/cerez-politikasi"
LinkComponent={Link}
labels={labels}
cookies={cookies}
theme={{
primary: "#2563eb",
background: "rgba(15, 23, 42, 0.97)",
text: "#f8fafc",
link: "#93c5fd",
accent: "#2563eb",
}}
onConsentError={({ reason }) => {
console.warn("[demo] consent error:", reason);
}}
/>
</>
);
}
export function CookieSettingsButton() {
return (
<button type="button" onClick={openPreferences}>
Çerez ayarları
</button>
);
}

View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}

View File

@ -0,0 +1,9 @@
import { configureConsent } from "@pdntechnology/cookie-consent";
configureConsent({
storageKey: "demo_cookie_consent",
waitForUpdateMs: 500,
adsDataRedaction: true,
urlPassthrough: true,
debug: false,
});

View File

@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@pdntechnology/cookie-consent"],
outputFileTracingRoot: import.meta.dirname,
};
export default nextConfig;

928
examples/next-app/package-lock.json generated Normal file
View File

@ -0,0 +1,928 @@
{
"name": "cookie-consent-next-example",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cookie-consent-next-example",
"version": "0.0.0",
"dependencies": {
"@pdntechnology/cookie-consent": "file:../..",
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
},
"../..": {
"name": "@pdntechnology/cookie-consent",
"version": "1.1.0",
"license": "MIT",
"devDependencies": {
"vitest": "^3.2.4"
},
"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
}
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@next/env": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
"integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
"integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
"integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
"integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
"integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
"integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
"integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
"integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@pdntechnology/cookie-consent": {
"resolved": "../..",
"link": true
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/nanoid": {
"version": "3.3.13",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
"integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "15.5.19",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
"integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.19",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.19",
"@next/swc-darwin-x64": "15.5.19",
"@next/swc-linux-arm64-gnu": "15.5.19",
"@next/swc-linux-arm64-musl": "15.5.19",
"@next/swc-linux-x64-gnu": "15.5.19",
"@next/swc-linux-x64-musl": "15.5.19",
"@next/swc-win32-arm64-msvc": "15.5.19",
"@next/swc-win32-x64-msvc": "15.5.19",
"sharp": "^0.34.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}

View File

@ -0,0 +1,16 @@
{
"name": "cookie-consent-next-example",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@pdntechnology/cookie-consent": "file:../..",
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}

1602
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@pdntechnology/cookie-consent",
"version": "1.0.0",
"version": "1.1.0",
"description": "Lightweight Google Consent Mode v2 CMP for React and Next.js",
"type": "module",
"main": "./src/index.js",
@ -9,18 +9,22 @@
"sideEffects": ["./src/styles.css"],
"exports": {
".": {
"types": "./src/index.d.ts",
"import": "./src/index.js",
"default": "./src/index.js"
},
"./react": {
"types": "./src/react/index.d.ts",
"import": "./src/react/index.js",
"default": "./src/react/index.js"
},
"./styles.css": "./src/styles.css"
},
"files": ["src", "README.md", "LICENSE"],
"files": ["src", "README.md", "README.tr.md", "LICENSE", "CHANGELOG.md", "CONTRIBUTING.md"],
"scripts": {
"prepublishOnly": "test -f README.md && test -f LICENSE"
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm test && test -f README.md && test -f LICENSE"
},
"peerDependencies": {
"next": ">=14.0.0",
@ -32,6 +36,9 @@
"react": { "optional": true },
"react-dom": { "optional": true }
},
"devDependencies": {
"vitest": "^3.2.4"
},
"keywords": [
"cookie-consent",
"consent-mode",
@ -47,12 +54,12 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/pdntechnology/cookie-consent.git"
"url": "https://git.pdntechnology.com.tr/pdn/cookie-consent.git"
},
"bugs": {
"url": "https://github.com/pdntechnology/cookie-consent/issues"
"url": "https://git.pdntechnology.com.tr/pdn/cookie-consent/issues"
},
"homepage": "https://github.com/pdntechnology/cookie-consent#readme",
"homepage": "https://git.pdntechnology.com.tr/pdn/cookie-consent#readme",
"publishConfig": {
"access": "public"
}

View File

@ -1,3 +1,4 @@
import { getConsentConfig } from "./config.js";
import { EVENT_CONSENT_UPDATED, EVENT_OPEN_PREFERENCES } from "./constants.js";
import { applyConsentUpdate } from "./google/consent-lifecycle.js";
import { parseUserConsent } from "./preferences.js";
@ -23,26 +24,63 @@ export function hasMarketingConsent() {
// ─── Write ──────────────────────────────────────────────────────────────────
/**
* @param {unknown} reason
* @param {unknown} [detail]
*/
function reportConsentError(reason, detail) {
const config = getConsentConfig();
if (config.debug) {
console.warn("[cookie-consent]", reason, detail ?? "");
}
if (typeof config.onStorageError === "function") {
config.onStorageError({ reason, detail });
}
}
/**
* @param {import("./types.js").UserConsent | Partial<import("./types.js").UserConsent> | "accepted" | "rejected"} preferences
* @returns {import("./types.js").PersistResult}
*/
function persistConsent(preferences) {
const consent = parseUserConsent(preferences);
if (!consent) return;
if (!consent) {
reportConsentError("invalid", preferences);
return { ok: false, reason: "invalid" };
}
if (!writeStoredConsent(consent)) return;
if (!writeStoredConsent(consent)) {
reportConsentError("storage", consent);
return { ok: false, reason: "storage" };
}
applyConsentUpdate(consent);
window.dispatchEvent(new CustomEvent(EVENT_CONSENT_UPDATED, { detail: consent }));
return { ok: true, consent };
}
/**
* @param {import("./types.js").UserConsent | Partial<import("./types.js").UserConsent>} preferences
* @returns {import("./types.js").PersistResult}
*/
export function setConsent(preferences) {
persistConsent(preferences);
return persistConsent(preferences);
}
/**
* @returns {import("./types.js").PersistResult}
*/
export function acceptAll() {
persistConsent({ analytics: true, marketing: true });
return persistConsent({ analytics: true, marketing: true });
}
/**
* @returns {import("./types.js").PersistResult}
*/
export function rejectAll() {
persistConsent({ analytics: false, marketing: false });
return persistConsent({ analytics: false, marketing: false });
}
// ─── Sync & UI ──────────────────────────────────────────────────────────────

61
src/consent-api.test.js Normal file
View File

@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureConsent } from "./config.js";
import { EVENT_CONSENT_UPDATED } from "./constants.js";
import { acceptAll, rejectAll, setConsent } from "./consent-api.js";
const storage = new Map();
const dataLayer = [];
describe("consent-api", () => {
beforeEach(() => {
storage.clear();
dataLayer.length = 0;
configureConsent({ storageKey: "test_consent", debug: false });
vi.stubGlobal("window", {
localStorage: {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => {
storage.set(key, value);
},
},
dataLayer,
dispatchEvent: vi.fn((event) => {
window.__lastEvent = event;
}),
});
vi.stubGlobal("dataLayer", dataLayer);
window.dataLayer = dataLayer;
window.gtag = (...args) => {
dataLayer.push(args);
};
});
it("acceptAll persists consent and dispatches event", () => {
const result = acceptAll();
expect(result).toEqual({ ok: true, consent: { analytics: true, marketing: true } });
expect(window.__lastEvent.type).toBe(EVENT_CONSENT_UPDATED);
expect(dataLayer.some((entry) => entry[0] === "consent" && entry[1] === "update")).toBe(true);
});
it("rejectAll denies optional categories", () => {
const result = rejectAll();
expect(result).toEqual({ ok: true, consent: { analytics: false, marketing: false } });
});
it("returns invalid when preferences cannot be parsed", () => {
const result = setConsent(null);
expect(result).toEqual({ ok: false, reason: "invalid" });
});
it("returns storage error when localStorage write fails", () => {
window.localStorage.setItem = () => {
throw new Error("blocked");
};
const result = setConsent({ analytics: true, marketing: true });
expect(result).toEqual({ ok: false, reason: "storage" });
});
});

57
src/cookie-schema.js Normal file
View File

@ -0,0 +1,57 @@
/** @typedef {"necessary"|"analytics"|"marketing"} CookieCategory */
/**
* @typedef {Object} CookieEntry
* @property {CookieCategory} category
* @property {string} name
* @property {string} purpose
* @property {string} duration
* @property {string} provider
*/
export const COOKIE_CATEGORIES = ["necessary", "analytics", "marketing"];
/**
* @param {unknown} entry
* @returns {entry is CookieEntry}
*/
export function isValidCookieEntry(entry) {
if (!entry || typeof entry !== "object") return false;
const { category, name, purpose, duration, provider } = entry;
return (
COOKIE_CATEGORIES.includes(category) &&
typeof name === "string" &&
name.length > 0 &&
typeof purpose === "string" &&
typeof duration === "string" &&
typeof provider === "string"
);
}
/**
* @param {unknown[]} cookies
* @returns {CookieEntry[]}
*/
export function normalizeCookieEntries(cookies) {
if (!Array.isArray(cookies)) return [];
return cookies.filter(isValidCookieEntry);
}
/**
* @param {CookieEntry[]} cookies
* @returns {Record<CookieCategory, CookieEntry[]>}
*/
export function groupCookiesByCategory(cookies) {
/** @type {Record<CookieCategory, CookieEntry[]>} */
const grouped = {
necessary: [],
analytics: [],
marketing: [],
};
for (const entry of cookies) {
grouped[entry.category].push(entry);
}
return grouped;
}

61
src/cookie-schema.test.js Normal file
View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
groupCookiesByCategory,
isValidCookieEntry,
normalizeCookieEntries,
} from "./cookie-schema.js";
describe("cookie-schema", () => {
it("validates cookie entries", () => {
expect(
isValidCookieEntry({
category: "analytics",
name: "_ga",
purpose: "Analytics",
duration: "2 years",
provider: "Google",
}),
).toBe(true);
expect(isValidCookieEntry({ category: "unknown", name: "_ga" })).toBe(false);
});
it("normalizes invalid entries out of the list", () => {
const cookies = normalizeCookieEntries([
{
category: "marketing",
name: "_fbp",
purpose: "Ads",
duration: "3 months",
provider: "Meta",
},
{ category: "invalid", name: "bad" },
]);
expect(cookies).toHaveLength(1);
expect(cookies[0].name).toBe("_fbp");
});
it("groups cookies by category", () => {
const grouped = groupCookiesByCategory([
{
category: "analytics",
name: "_ga",
purpose: "Analytics",
duration: "2 years",
provider: "Google",
},
{
category: "necessary",
name: "ccm_consent",
purpose: "Preferences",
duration: "1 year",
provider: "Site",
},
]);
expect(grouped.analytics).toHaveLength(1);
expect(grouped.necessary).toHaveLength(1);
expect(grouped.marketing).toHaveLength(0);
});
});

View File

@ -1,24 +1,38 @@
import { getConsentConfig } from "../config.js";
import { getDefaultDeniedState } from "./consent-signals.js";
function buildBootstrapDefaults(config) {
const wait = Number(config.waitForUpdateMs) || 500;
const defaults = getDefaultDeniedState(wait);
if (config.adsDataRedaction !== false) {
defaults.ads_data_redaction = true;
}
if (config.urlPassthrough !== false) {
defaults.url_passthrough = true;
}
return defaults;
}
/**
* 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 bootstrapDefaults = buildBootstrapDefaults(config);
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 });
const payload = JSON.stringify({ ...bootstrapDefaults, ...signals, region: regions });
return `gtag('consent','default',${payload});`;
})
.join("\n");
const globalDefault = JSON.stringify(getDefaultDeniedState(wait));
const globalDefault = JSON.stringify(bootstrapDefaults);
return `
window.dataLayer=window.dataLayer||[];

23
src/google/bootstrap-script.test.js vendored Normal file
View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { configureConsent } from "../config.js";
import { getConsentBootstrapScript } from "./bootstrap-script.js";
describe("getConsentBootstrapScript", () => {
it("includes denied defaults and advanced settings", () => {
configureConsent({
waitForUpdateMs: 500,
adsDataRedaction: true,
urlPassthrough: true,
regionDefaults: [],
});
const script = getConsentBootstrapScript();
expect(script).toContain("gtag('consent','default'");
expect(script).toContain('"analytics_storage":"denied"');
expect(script).toContain('"security_storage":"granted"');
expect(script).toContain('"ads_data_redaction":true');
expect(script).toContain('"url_passthrough":true');
expect(script).toContain('"wait_for_update":500');
});
});

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { buildConsentSignals, buildAdvancedSettings, getDefaultDeniedState } from "./consent-signals.js";
describe("buildConsentSignals", () => {
it("denies optional categories by default", () => {
expect(buildConsentSignals(null)).toEqual({
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
functionality_storage: "denied",
personalization_storage: "denied",
security_storage: "granted",
});
});
it("grants analytics signals when analytics is true", () => {
const signals = buildConsentSignals({ analytics: true, marketing: false });
expect(signals.analytics_storage).toBe("granted");
expect(signals.ad_storage).toBe("denied");
});
it("grants marketing signals when marketing is true", () => {
const signals = buildConsentSignals({ analytics: false, marketing: true });
expect(signals.analytics_storage).toBe("denied");
expect(signals.ad_storage).toBe("granted");
expect(signals.ad_user_data).toBe("granted");
expect(signals.ad_personalization).toBe("granted");
expect(signals.functionality_storage).toBe("granted");
expect(signals.personalization_storage).toBe("granted");
});
});
describe("buildAdvancedSettings", () => {
it("enables redaction when marketing is denied", () => {
expect(
buildAdvancedSettings({ marketing: false }, {
adsDataRedaction: true,
urlPassthrough: true,
}),
).toEqual({
ads_data_redaction: true,
url_passthrough: true,
});
});
it("disables redaction when marketing is granted", () => {
expect(
buildAdvancedSettings({ marketing: true }, {
adsDataRedaction: true,
urlPassthrough: true,
}),
).toEqual({
ads_data_redaction: false,
url_passthrough: false,
});
});
});
describe("getDefaultDeniedState", () => {
it("includes wait_for_update", () => {
expect(getDefaultDeniedState(750).wait_for_update).toBe(750);
});
});

41
src/index.d.ts vendored
View File

@ -21,8 +21,21 @@ export interface ConsentConfig {
urlPassthrough?: boolean;
regionDefaults?: RegionDefault[];
debug?: boolean;
onStorageError?: (error: { reason: "invalid" | "storage"; detail?: unknown }) => void;
}
export interface ConsentResult {
ok: true;
consent: UserConsent;
}
export interface ConsentError {
ok: false;
reason: "invalid" | "storage";
}
export type PersistResult = ConsentResult | ConsentError;
export const DEFAULT_STORAGE_KEY: string;
export const EVENT_CONSENT_UPDATED: string;
export const EVENT_OPEN_PREFERENCES: string;
@ -34,9 +47,9 @@ 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 setConsent(preferences: Partial<UserConsent>): PersistResult;
export function acceptAll(): PersistResult;
export function rejectAll(): PersistResult;
export function hasAnalyticsConsent(): boolean;
export function hasMarketingConsent(): boolean;
export function hasConsentChoice(): boolean;
@ -48,15 +61,31 @@ export function applyConsentUpdate(consent: UserConsent | null | undefined): voi
export function buildGoogleConsentState(consent: UserConsent | null | undefined): Record<string, "granted" | "denied">;
export function getConsentBootstrapScript(options?: Partial<ConsentConfig>): string;
export type CookieCategory = "necessary" | "analytics" | "marketing";
export interface CookieEntry {
category: CookieCategory;
name: string;
purpose: string;
duration: string;
provider: string;
}
export const COOKIE_CATEGORIES: readonly CookieCategory[];
export function isValidCookieEntry(entry: unknown): entry is CookieEntry;
export function normalizeCookieEntries(cookies: unknown[]): CookieEntry[];
export function groupCookiesByCategory(cookies: CookieEntry[]): Record<CookieCategory, CookieEntry[]>;
// 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 setCookieConsent(preferences: Partial<UserConsent>): PersistResult;
export function acceptAllCookies(): PersistResult;
export function rejectAllCookies(): PersistResult;
export function hasCookieConsentChoice(): boolean;
export function syncGoogleConsentFromStorage(): void;
export function openCookiePreferences(): void;

View File

@ -36,6 +36,14 @@ export {
openCookiePreferences,
} from "./consent-api.js";
// Cookie inventory helpers
export {
COOKIE_CATEGORIES,
groupCookiesByCategory,
isValidCookieEntry,
normalizeCookieEntries,
} from "./cookie-schema.js";
// Google Consent Mode
export { applyConsentDefaults, applyConsentUpdate } from "./google/consent-lifecycle.js";
export { buildConsentSignals as buildGoogleConsentState } from "./google/consent-signals.js";

34
src/preferences.test.js Normal file
View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { parseUserConsent, serializeUserConsent } from "./preferences.js";
describe("parseUserConsent", () => {
it("parses legacy accepted/rejected strings", () => {
expect(parseUserConsent("accepted")).toEqual({ analytics: true, marketing: true });
expect(parseUserConsent("rejected")).toEqual({ analytics: false, marketing: false });
});
it("normalises partial objects", () => {
expect(parseUserConsent({ analytics: 1, marketing: 0 })).toEqual({
analytics: true,
marketing: false,
});
});
it("returns null for invalid values", () => {
expect(parseUserConsent(null)).toBeNull();
expect(parseUserConsent(undefined)).toBeNull();
expect(parseUserConsent("maybe")).toBeNull();
});
});
describe("serializeUserConsent", () => {
it("stores version and timestamp metadata", () => {
const raw = serializeUserConsent({ analytics: true, marketing: false });
const parsed = JSON.parse(raw);
expect(parsed.analytics).toBe(true);
expect(parsed.marketing).toBe(false);
expect(parsed.version).toBe(1);
expect(typeof parsed.updatedAt).toBe("string");
});
});

View File

@ -1,7 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { groupCookiesByCategory, normalizeCookieEntries } from "../cookie-schema.js";
import { getStorageKey } from "../config.js";
import { EVENT_OPEN_PREFERENCES } from "../constants.js";
import {
acceptAll,
@ -10,6 +12,8 @@ import {
rejectAll,
setConsent,
} from "../consent-api.js";
import CookieList from "./CookieList.jsx";
import { buildThemeStyle } from "./theme.js";
const DEFAULT_LABELS = {
title: "Cookie preferences",
@ -26,6 +30,12 @@ const DEFAULT_LABELS = {
marketingTitle: "Marketing",
marketingDescription: "Used for ads and remarketing (Google Ads, Meta Pixel).",
alwaysOn: "Always on",
cookiesHeading: "View cookies",
cookieName: "Name",
cookiePurpose: "Purpose",
cookieDuration: "Duration",
cookieProvider: "Provider",
storageError: "Your preferences could not be saved. Please try again.",
};
/**
@ -34,16 +44,29 @@ const DEFAULT_LABELS = {
*/
export default function CookieConsent({
labels: labelOverrides,
theme,
cookies,
policyHref = "/privacy",
LinkComponent = Link,
className = "",
onConsentSaved,
onConsentError,
}) {
const labels = { ...DEFAULT_LABELS, ...labelOverrides };
const PolicyLink = LinkComponent;
const themeStyle = useMemo(() => buildThemeStyle(theme), [theme]);
const groupedCookies = useMemo(
() => groupCookiesByCategory(normalizeCookieEntries(cookies)),
[cookies],
);
const hasCookieInventory = Boolean(cookies?.length);
const bannerRef = useRef(null);
const [visible, setVisible] = useState(false);
const [mode, setMode] = useState("banner");
const [analytics, setAnalytics] = useState(false);
const [marketing, setMarketing] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
setVisible(!hasConsentChoice());
@ -56,42 +79,110 @@ export default function CookieConsent({
setMarketing(current?.marketing ?? false);
setMode("preferences");
setVisible(true);
setErrorMessage("");
}
window.addEventListener(EVENT_OPEN_PREFERENCES, handleOpenPreferences);
return () => window.removeEventListener(EVENT_OPEN_PREFERENCES, handleOpenPreferences);
}, []);
useEffect(() => {
function handleStorage(event) {
if (event.key !== getStorageKey()) return;
if (event.newValue) {
setVisible(false);
setErrorMessage("");
return;
}
setMode("banner");
setVisible(true);
}
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
useEffect(() => {
if (!visible) return;
const firstFocusable = bannerRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
}, [visible, mode]);
useEffect(() => {
if (!visible) return;
function handleKeyDown(event) {
if (event.key !== "Escape") return;
if (mode === "preferences" && !hasConsentChoice()) {
setMode("banner");
setErrorMessage("");
return;
}
setVisible(false);
setErrorMessage("");
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [visible, mode]);
if (!visible) return null;
function close() {
setVisible(false);
setErrorMessage("");
}
function handlePersistResult(result) {
if (result.ok) {
setErrorMessage("");
onConsentSaved?.(result.consent);
close();
return;
}
setErrorMessage(labels.storageError);
onConsentError?.({ reason: result.reason });
}
function handleAcceptAll() {
acceptAll();
close();
handlePersistResult(acceptAll());
}
function handleRejectAll() {
rejectAll();
close();
handlePersistResult(rejectAll());
}
function handleSave() {
setConsent({ analytics, marketing });
close();
handlePersistResult(setConsent({ analytics, marketing }));
}
const bannerClassName = ["ccm-banner", className].filter(Boolean).join(" ");
return (
<div
className="ccm-banner"
ref={bannerRef}
className={bannerClassName}
style={themeStyle}
role="dialog"
aria-live="polite"
aria-label={labels.title}
aria-modal="true"
>
<div className="ccm-banner__inner">
{errorMessage ? (
<p className="ccm-banner__error" role="alert">
{errorMessage}
</p>
) : null}
{mode === "banner" ? (
<BannerView
labels={labels}
@ -99,7 +190,10 @@ export default function CookieConsent({
policyHref={policyHref}
onAccept={handleAcceptAll}
onReject={handleRejectAll}
onManage={() => setMode("preferences")}
onManage={() => {
setMode("preferences");
setErrorMessage("");
}}
/>
) : (
<PreferencesView
@ -108,6 +202,8 @@ export default function CookieConsent({
policyHref={policyHref}
analytics={analytics}
marketing={marketing}
groupedCookies={groupedCookies}
hasCookieInventory={hasCookieInventory}
onAnalyticsChange={setAnalytics}
onMarketingChange={setMarketing}
onSave={handleSave}
@ -148,6 +244,8 @@ function PreferencesView({
policyHref,
analytics,
marketing,
groupedCookies,
hasCookieInventory,
onAnalyticsChange,
onMarketingChange,
onSave,
@ -170,18 +268,24 @@ function PreferencesView({
description={labels.necessaryDescription}
locked
badge={labels.alwaysOn}
cookies={hasCookieInventory ? groupedCookies.necessary : null}
labels={labels}
/>
<CategoryCard
title={labels.analyticsTitle}
description={labels.analyticsDescription}
checked={analytics}
onChange={onAnalyticsChange}
cookies={hasCookieInventory ? groupedCookies.analytics : null}
labels={labels}
/>
<CategoryCard
title={labels.marketingTitle}
description={labels.marketingDescription}
checked={marketing}
onChange={onMarketingChange}
cookies={hasCookieInventory ? groupedCookies.marketing : null}
labels={labels}
/>
</div>
@ -200,7 +304,7 @@ function PreferencesView({
);
}
function CategoryCard({ title, description, checked, onChange, locked, badge }) {
function CategoryCard({ title, description, checked, onChange, locked, badge, cookies, labels }) {
if (locked) {
return (
<div className="ccm-banner__category ccm-banner__category--locked">
@ -209,6 +313,7 @@ function CategoryCard({ title, description, checked, onChange, locked, badge })
<span className="ccm-banner__badge">{badge}</span>
</div>
<p>{description}</p>
{cookies ? <CookieList cookies={cookies} labels={labels} /> : null}
</div>
);
}
@ -220,6 +325,7 @@ function CategoryCard({ title, description, checked, onChange, locked, badge })
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
</div>
<p>{description}</p>
{cookies ? <CookieList cookies={cookies} labels={labels} /> : null}
</label>
);
}

32
src/react/CookieList.jsx Normal file
View File

@ -0,0 +1,32 @@
/**
* Expandable cookie inventory table for a single category.
*/
export default function CookieList({ cookies, labels }) {
if (!cookies?.length) return null;
return (
<details className="ccm-banner__cookies">
<summary>{labels.cookiesHeading}</summary>
<table className="ccm-banner__cookie-table">
<thead>
<tr>
<th scope="col">{labels.cookieName}</th>
<th scope="col">{labels.cookiePurpose}</th>
<th scope="col">{labels.cookieDuration}</th>
<th scope="col">{labels.cookieProvider}</th>
</tr>
</thead>
<tbody>
{cookies.map((cookie) => (
<tr key={`${cookie.category}-${cookie.name}`}>
<td>{cookie.name}</td>
<td>{cookie.purpose}</td>
<td>{cookie.duration}</td>
<td>{cookie.provider}</td>
</tr>
))}
</tbody>
</table>
</details>
);
}

89
src/react/index.d.ts vendored Normal file
View File

@ -0,0 +1,89 @@
import type { ComponentType, CSSProperties } from "react";
import type {
ConsentConfig,
CookieEntry,
PersistResult,
UserConsent,
} from "../index.js";
export interface CookieConsentLabels {
title?: string;
message?: string;
policyLink?: string;
accept?: string;
reject?: string;
manage?: string;
save?: string;
necessaryTitle?: string;
necessaryDescription?: string;
analyticsTitle?: string;
analyticsDescription?: string;
marketingTitle?: string;
marketingDescription?: string;
alwaysOn?: string;
cookiesHeading?: string;
cookieName?: string;
cookiePurpose?: string;
cookieDuration?: string;
cookieProvider?: string;
storageError?: string;
}
export interface CookieConsentTheme {
background?: string;
text?: string;
textMuted?: string;
link?: string;
primary?: string;
primaryText?: string;
secondaryBackground?: string;
border?: string;
accent?: string;
}
export interface CookieConsentProps {
labels?: Partial<CookieConsentLabels>;
theme?: CookieConsentTheme;
cookies?: CookieEntry[];
policyHref?: string;
LinkComponent?: ComponentType<{ href: string; children: React.ReactNode }>;
className?: string;
onConsentSaved?: (consent: UserConsent) => void;
onConsentError?: (error: { reason: "invalid" | "storage" }) => void;
}
export interface ConsentBootstrapProps {
overrides?: Partial<ConsentConfig>;
}
export interface GoogleTagManagerProps {
gtmId: string;
}
export interface FacebookPixelProps {
pixelId: string;
}
export const CookieConsent: ComponentType<CookieConsentProps>;
export const ConsentBootstrap: ComponentType<ConsentBootstrapProps>;
export const GoogleTagManager: ComponentType<GoogleTagManagerProps>;
export const FacebookPixel: ComponentType<FacebookPixelProps>;
export function configureConsent(config?: ConsentConfig): ConsentConfig;
export function getConsent(): UserConsent | null;
export function setConsent(preferences: Partial<UserConsent>): PersistResult;
export function acceptAll(): PersistResult;
export function rejectAll(): PersistResult;
export function hasAnalyticsConsent(): boolean;
export function hasMarketingConsent(): boolean;
export function openPreferences(): void;
export function syncConsentFromStorage(): void;
export function getCookieConsent(): UserConsent | null;
export function setCookieConsent(preferences: Partial<UserConsent>): PersistResult;
export function acceptAllCookies(): PersistResult;
export function rejectAllCookies(): PersistResult;
export function openCookiePreferences(): void;
export function syncGoogleConsentFromStorage(): void;
export function buildThemeStyle(theme?: CookieConsentTheme): CSSProperties;

View File

@ -2,6 +2,7 @@ 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 { buildThemeStyle, THEME_VAR_MAP } from "./theme.js";
export { configureConsent } from "../config.js";
export {

29
src/react/theme.js Normal file
View File

@ -0,0 +1,29 @@
/** Maps theme prop keys to CSS custom property names. */
export const THEME_VAR_MAP = {
background: "--ccm-bg",
text: "--ccm-text",
textMuted: "--ccm-text-muted",
link: "--ccm-link",
primary: "--ccm-primary",
primaryText: "--ccm-primary-text",
secondaryBackground: "--ccm-secondary-bg",
border: "--ccm-border",
accent: "--ccm-accent",
};
/**
* Build inline style object for CSS variable overrides.
* @param {Record<string, string>|undefined} theme
* @returns {Record<string, string>}
*/
export function buildThemeStyle(theme) {
if (!theme || typeof theme !== "object") return {};
const style = {};
for (const [key, value] of Object.entries(theme)) {
if (value == null || value === "") continue;
const cssVar = THEME_VAR_MAP[key];
if (cssVar) style[cssVar] = value;
}
return style;
}

24
src/react/theme.test.js Normal file
View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { buildThemeStyle, THEME_VAR_MAP } from "./theme.js";
describe("buildThemeStyle", () => {
it("maps theme keys to CSS custom properties", () => {
expect(
buildThemeStyle({
primary: "#2563eb",
background: "#0f172a",
}),
).toEqual({
[THEME_VAR_MAP.primary]: "#2563eb",
[THEME_VAR_MAP.background]: "#0f172a",
});
});
it("ignores empty values", () => {
expect(buildThemeStyle({ primary: "", text: null })).toEqual({});
});
it("returns empty object when theme is missing", () => {
expect(buildThemeStyle()).toEqual({});
});
});

41
src/storage.test.js Normal file
View File

@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureConsent } from "./config.js";
import { readStoredConsent, writeStoredConsent } from "./storage.js";
const storage = new Map();
describe("storage", () => {
beforeEach(() => {
storage.clear();
configureConsent({ storageKey: "test_consent" });
vi.stubGlobal("window", {
localStorage: {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => {
storage.set(key, value);
},
},
});
});
it("returns null when nothing is stored", () => {
expect(readStoredConsent()).toBeNull();
});
it("reads and writes consent objects", () => {
writeStoredConsent({ analytics: true, marketing: false });
expect(readStoredConsent()).toEqual({ analytics: true, marketing: false });
});
it("falls back to legacy plain string values", () => {
storage.set("test_consent", "accepted");
expect(readStoredConsent()).toEqual({ analytics: true, marketing: true });
});
it("returns null on SSR", () => {
vi.stubGlobal("window", undefined);
expect(readStoredConsent()).toBeNull();
expect(writeStoredConsent({ analytics: true, marketing: true })).toBe(false);
});
});

View File

@ -1,14 +1,24 @@
/* Cookie Consent Module — banner styles */
.ccm-banner {
--ccm-bg: rgba(5, 22, 52, 0.96);
--ccm-text: #ffffff;
--ccm-text-muted: rgba(255, 255, 255, 0.82);
--ccm-link: #ffffff;
--ccm-primary: #4a90d9;
--ccm-primary-text: #ffffff;
--ccm-secondary-bg: rgba(255, 255, 255, 0.1);
--ccm-border: rgba(255, 255, 255, 0.14);
--ccm-accent: #4a90d9;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10050;
padding: 16px;
background: rgba(5, 22, 52, 0.96);
color: #fff;
background: var(--ccm-bg);
color: var(--ccm-text);
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2);
}
@ -30,7 +40,7 @@
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--ccm-text);
}
.ccm-banner__text {
@ -42,10 +52,22 @@
}
.ccm-banner__text a {
color: #fff;
color: var(--ccm-link);
text-decoration: underline;
}
.ccm-banner__error {
flex: 1 1 100%;
margin: 0;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
line-height: 1.45;
color: #fecaca;
background: rgba(220, 38, 38, 0.18);
border: 1px solid rgba(248, 113, 113, 0.35);
}
.ccm-banner__categories {
flex: 1 1 100%;
display: grid;
@ -56,7 +78,7 @@
margin: 0;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--ccm-border);
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
@ -70,7 +92,7 @@
margin: 6px 0 0;
font-size: 13px;
line-height: 1.45;
color: rgba(255, 255, 255, 0.82);
color: var(--ccm-text-muted);
}
.ccm-banner__category-head {
@ -88,7 +110,7 @@
.ccm-banner__category-head input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #4a90d9;
accent-color: var(--ccm-accent);
cursor: pointer;
}
@ -103,6 +125,54 @@
color: rgba(255, 255, 255, 0.9);
}
.ccm-banner__cookies {
margin-top: 10px;
}
.ccm-banner__cookies summary {
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--ccm-link);
list-style: none;
}
.ccm-banner__cookies summary::-webkit-details-marker {
display: none;
}
.ccm-banner__cookies summary::before {
content: "▸ ";
}
.ccm-banner__cookies[open] summary::before {
content: "▾ ";
}
.ccm-banner__cookie-table {
width: 100%;
margin-top: 8px;
border-collapse: collapse;
font-size: 12px;
}
.ccm-banner__cookie-table th,
.ccm-banner__cookie-table td {
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid var(--ccm-border);
vertical-align: top;
}
.ccm-banner__cookie-table th {
font-weight: 600;
color: var(--ccm-text);
}
.ccm-banner__cookie-table td {
color: var(--ccm-text-muted);
}
.ccm-banner__actions {
display: flex;
flex-wrap: wrap;
@ -123,9 +193,9 @@
}
.ccm-banner__btn--primary {
background: #4a90d9;
border: 1px solid #4a90d9;
color: #fff;
background: var(--ccm-primary);
border: 1px solid var(--ccm-primary);
color: var(--ccm-primary-text);
}
.ccm-banner__btn--primary:hover {
@ -133,9 +203,9 @@
}
.ccm-banner__btn--secondary {
background: rgba(255, 255, 255, 0.1);
background: var(--ccm-secondary-bg);
border: 1px solid rgba(255, 255, 255, 0.28);
color: #fff;
color: var(--ccm-text);
}
.ccm-banner__btn--secondary:hover {
@ -145,7 +215,7 @@
.ccm-banner__btn--ghost {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35);
color: #fff;
color: var(--ccm-text);
}
.ccm-banner__btn--ghost:hover {
@ -161,4 +231,9 @@
flex: 1 1 auto;
text-align: center;
}
.ccm-banner__cookie-table {
display: block;
overflow-x: auto;
}
}

46
src/types.js Normal file
View File

@ -0,0 +1,46 @@
/**
* @typedef {Object} UserConsent
* @property {boolean} analytics
* @property {boolean} marketing
*/
/**
* @typedef {Object} RegionDefault
* @property {string[]} regions
* @property {"granted"|"denied"} [ad_storage]
* @property {"granted"|"denied"} [analytics_storage]
* @property {"granted"|"denied"} [ad_user_data]
* @property {"granted"|"denied"} [ad_personalization]
* @property {"granted"|"denied"} [functionality_storage]
* @property {"granted"|"denied"} [personalization_storage]
* @property {"granted"|"denied"} [security_storage]
*/
/**
* @typedef {Object} ConsentConfig
* @property {string} [storageKey]
* @property {number} [waitForUpdateMs]
* @property {boolean} [adsDataRedaction]
* @property {boolean} [urlPassthrough]
* @property {RegionDefault[]} [regionDefaults]
* @property {boolean} [debug]
* @property {(error: { reason: string, detail?: unknown }) => void} [onStorageError]
*/
/**
* @typedef {Object} ConsentResult
* @property {true} ok
* @property {UserConsent} consent
*/
/**
* @typedef {Object} ConsentError
* @property {false} ok
* @property {"invalid"|"storage"} reason
*/
/**
* @typedef {ConsentResult|ConsentError} PersistResult
*/
export {};

8
vitest.config.js Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.js"],
},
});