feat: KVKK/GDPR uyumlu cookie consent + Google Consent Mode v2

Yeni component'ler:
- CookieBanner (sağ alt banner + tam ekran ayarlar modal)
  - 'Tümünü kabul', 'Tümünü reddet', 'Ayarları yönet'
  - 4 kategori toggle: Zorunlu / Analitik / Reklam / Tercih
  - Zorunlu kategori her zaman açık (KVKK)
  - localStorage + cookie persistence (12 ay TTL)
  - Versionlı (CONSENT_VERSION=1) — şema değişince yeniden sorma
  - window.openCookieSettings() global helper (footer/policy sayfasından çağrılabilir)
- ConsentInit (Google Consent Mode v2 default deny)
  - beforeInteractive Script ile gtag default deny yüklenir
  - User onayladığında banner gtag('consent','update', ...) çağırır
  - seo_settings.gtm_id doluysa GTM injection (asenkron)
- CookieSettingsButton (politika sayfasında 'ayarları değiştir')

Yeni sayfa:
- /cerez-politikasi — KVKK uyumlu çerez politikası metni
  - 4 kategori detaylı açıklama + örnek çerez isimleri
  - KVKK Madde 11 kapsamındaki kullanıcı hakları
  - İletişim bilgileri site_settings'ten

Layout entegrasyonu:
- app/layout.tsx — ConsentInit head'e, CookieBanner body sonuna
- Footer'a 'Çerez Politikası' linki

Consent flag mapping (Consent Mode v2):
- Zorunlu → functionality_storage + security_storage (her zaman granted)
- Analitik → analytics_storage
- Reklam → ad_storage + ad_user_data + ad_personalization
- Tercih → personalization_storage

Önemli: Default state 'denied' — kullanıcı seçim yapmadan
hiçbir analytics/ads çerezi tetiklenmez. Google Ads Consent Mode v2 uyumlu.

31 route, public sayfalar static (1m revalidate).
This commit is contained in:
Ege Can Komur
2026-05-20 03:27:02 +03:00
parent 2373eccb17
commit 304a344955
7 changed files with 569 additions and 2 deletions
+321
View File
@@ -0,0 +1,321 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Cookie, Settings2, X } from "lucide-react";
import {
CONSENT_COOKIE_NAME,
CONSENT_STORAGE_KEY,
CONSENT_TTL_DAYS,
CONSENT_VERSION,
type ConsentState,
} from "@/lib/consent-types";
type Mode = "hidden" | "banner" | "settings";
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dataLayer?: any[];
gtag?: (...args: unknown[]) => void;
openCookieSettings?: () => void;
}
}
function setCookie(value: string) {
const expires = new Date();
expires.setDate(expires.getDate() + CONSENT_TTL_DAYS);
document.cookie = `${CONSENT_COOKIE_NAME}=${encodeURIComponent(value)}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
}
function loadConsent(): ConsentState | null {
try {
const raw = localStorage.getItem(CONSENT_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as ConsentState;
if (parsed.version !== CONSENT_VERSION) return null;
return parsed;
} catch {
return null;
}
}
function saveConsent(state: ConsentState) {
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state));
setCookie(JSON.stringify({ a: state.analytics, m: state.marketing, p: state.preferences }));
// Google Consent Mode v2 update
if (typeof window !== "undefined" && window.gtag) {
window.gtag("consent", "update", {
ad_storage: state.marketing ? "granted" : "denied",
ad_user_data: state.marketing ? "granted" : "denied",
ad_personalization: state.marketing ? "granted" : "denied",
analytics_storage: state.analytics ? "granted" : "denied",
functionality_storage: "granted",
personalization_storage: state.preferences ? "granted" : "denied",
security_storage: "granted",
});
}
window.dispatchEvent(new CustomEvent("kovak:consent-updated", { detail: state }));
}
export function CookieBanner() {
const [mode, setMode] = useState<Mode>("hidden");
const [analytics, setAnalytics] = useState(false);
const [marketing, setMarketing] = useState(false);
const [preferences, setPreferences] = useState(false);
useEffect(() => {
const existing = loadConsent();
if (existing) {
setAnalytics(existing.analytics);
setMarketing(existing.marketing);
setPreferences(existing.preferences);
setMode("hidden");
} else {
// Tarayıcı yüklendikten sonra göster, FCP'yi etkilememek için kısa gecikme
const t = setTimeout(() => setMode("banner"), 800);
return () => clearTimeout(t);
}
}, []);
useEffect(() => {
window.openCookieSettings = () => setMode("settings");
return () => {
delete window.openCookieSettings;
};
}, []);
const acceptAll = () => {
saveConsent({
necessary: true,
analytics: true,
marketing: true,
preferences: true,
timestamp: Date.now(),
version: CONSENT_VERSION,
});
setAnalytics(true);
setMarketing(true);
setPreferences(true);
setMode("hidden");
};
const rejectAll = () => {
saveConsent({
necessary: true,
analytics: false,
marketing: false,
preferences: false,
timestamp: Date.now(),
version: CONSENT_VERSION,
});
setAnalytics(false);
setMarketing(false);
setPreferences(false);
setMode("hidden");
};
const saveSelection = () => {
saveConsent({
necessary: true,
analytics,
marketing,
preferences,
timestamp: Date.now(),
version: CONSENT_VERSION,
});
setMode("hidden");
};
if (mode === "hidden") return null;
return (
<>
{/* Banner */}
{mode === "banner" && (
<div className="fixed inset-x-3 bottom-3 z-50 sm:inset-x-auto sm:bottom-5 sm:left-5 sm:right-5 md:max-w-2xl">
<div className="rounded-2xl border border-[var(--border)] bg-white p-5 shadow-2xl shadow-[var(--navy)]/15 sm:p-6">
<div className="flex items-start gap-3">
<div className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--navy-50)] text-[var(--navy)] sm:flex">
<Cookie className="size-5" />
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-[var(--navy)]">
Çerez tercihiniz
</h3>
<p className="mt-1 text-xs leading-relaxed text-[var(--muted)]">
Web sitemizde deneyiminizi geliştirmek, trafiği analiz etmek ve
reklam performansını ölçmek için çerezler kullanıyoruz.{" "}
<Link
href="/cerez-politikasi"
className="underline hover:text-[var(--navy)]"
>
Çerez Politikası
</Link>
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={acceptAll}
className="rounded-full bg-[var(--navy)] px-4 py-2 text-xs font-medium text-white transition hover:bg-[var(--navy-700)]"
>
Tümünü kabul et
</button>
<button
type="button"
onClick={rejectAll}
className="rounded-full border border-[var(--border)] bg-white px-4 py-2 text-xs font-medium text-[var(--navy)] transition hover:bg-[var(--navy-50)]"
>
Tümünü reddet
</button>
<button
type="button"
onClick={() => setMode("settings")}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-white px-4 py-2 text-xs font-medium text-[var(--muted)] transition hover:text-[var(--navy)]"
>
<Settings2 className="size-3.5" />
Ayarları yönet
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Settings Modal */}
{mode === "settings" && (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center"
onClick={() => setMode("banner")}
>
<div
className="w-full max-w-lg rounded-2xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between border-b border-[var(--border)] p-6">
<div>
<h3 className="text-lg font-semibold text-[var(--navy)]">
Çerez ayarları
</h3>
<p className="mt-1 text-xs text-[var(--muted)]">
Hangi çerez türlerine izin verdiğinizi seçin.
</p>
</div>
<button
type="button"
aria-label="Kapat"
onClick={() => setMode("banner")}
className="rounded-md p-1 text-[var(--muted)] hover:bg-[var(--navy-50)] hover:text-[var(--navy)]"
>
<X className="size-5" />
</button>
</div>
<div className="max-h-[60vh] space-y-4 overflow-y-auto p-6">
<CategoryRow
title="Zorunlu çerezler"
description="Site temel işlevleri için gerekli. Oturum, güvenlik, form gönderimi. Devre dışı bırakılamaz."
checked
disabled
/>
<CategoryRow
title="Analitik çerezler"
description="Ziyaretçi sayısı, sayfa görüntüleme, trafik kaynağı gibi anonim istatistikler. Google Analytics."
checked={analytics}
onChange={setAnalytics}
/>
<CategoryRow
title="Reklam çerezleri"
description="Sizinle ilgili olabilecek reklamların gösterilmesi ve reklam performansının ölçülmesi. Google Ads, Meta Pixel."
checked={marketing}
onChange={setMarketing}
/>
<CategoryRow
title="Tercih çerezleri"
description="Dil, bölge, görüntüleme tercihlerinizi hatırlamak için."
checked={preferences}
onChange={setPreferences}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] bg-[var(--navy-50)]/40 p-4">
<button
type="button"
onClick={rejectAll}
className="rounded-full border border-[var(--border)] bg-white px-4 py-2 text-xs font-medium text-[var(--muted)] hover:text-[var(--navy)]"
>
Tümünü reddet
</button>
<button
type="button"
onClick={saveSelection}
className="rounded-full border border-[var(--navy)] bg-white px-4 py-2 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
Seçimi kaydet
</button>
<button
type="button"
onClick={acceptAll}
className="rounded-full bg-[var(--navy)] px-4 py-2 text-xs font-medium text-white hover:bg-[var(--navy-700)]"
>
Tümünü kabul et
</button>
</div>
</div>
</div>
)}
</>
);
}
function CategoryRow({
title,
description,
checked,
disabled,
onChange,
}: {
title: string;
description: string;
checked: boolean;
disabled?: boolean;
onChange?: (v: boolean) => void;
}) {
return (
<div className="rounded-xl border border-[var(--border)] bg-white p-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-[var(--navy)]">{title}</p>
<p className="mt-1 text-xs leading-relaxed text-[var(--muted)]">
{description}
</p>
</div>
<label className="relative inline-flex shrink-0 cursor-pointer items-center">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange?.(e.target.checked)}
className="peer sr-only"
/>
<div
className={`h-6 w-11 rounded-full transition ${
disabled
? "bg-[var(--sky)]/60"
: "bg-[var(--border)] peer-checked:bg-[var(--navy)]"
}`}
/>
<div
className={`absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow transition ${
checked ? "translate-x-5" : ""
}`}
/>
</label>
</div>
</div>
);
}