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:
@@ -0,0 +1,52 @@
|
||||
import Script from "next/script";
|
||||
|
||||
/**
|
||||
* Google Consent Mode v2 — defaults set to "denied" before any tag loads.
|
||||
* After the user makes a choice, CookieBanner calls gtag('consent','update', ...).
|
||||
*
|
||||
* GTM/GA inject sadece site_settings.gtm_id doluysa yapılır.
|
||||
*/
|
||||
export function ConsentInit({ gtmId }: { gtmId?: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<Script id="consent-default" strategy="beforeInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
window.gtag = gtag;
|
||||
gtag('consent', 'default', {
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
functionality_storage: 'granted',
|
||||
personalization_storage: 'denied',
|
||||
security_storage: 'granted',
|
||||
wait_for_update: 500,
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
|
||||
{gtmId && (
|
||||
<>
|
||||
<Script id="gtm-script" strategy="afterInteractive">
|
||||
{`
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
|
||||
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
|
||||
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${gtmId}');
|
||||
`}
|
||||
</Script>
|
||||
<noscript>
|
||||
<iframe
|
||||
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
|
||||
height="0"
|
||||
width="0"
|
||||
style={{ display: "none", visibility: "hidden" }}
|
||||
/>
|
||||
</noscript>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Settings2 } from "lucide-react";
|
||||
|
||||
export function CookieSettingsButton() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.openCookieSettings?.()}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--navy)] px-4 py-2 text-xs font-medium text-white transition hover:bg-[var(--navy-700)]"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
Çerez ayarlarını değiştir
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,13 @@ export async function Footer() {
|
||||
<p>
|
||||
© {new Date().getFullYear()} {siteConfig.name}. Tüm hakları saklıdır.
|
||||
</p>
|
||||
<p>Kocaeli, Türkiye</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/cerez-politikasi" className="hover:text-white">
|
||||
Çerez Politikası
|
||||
</Link>
|
||||
<span>•</span>
|
||||
<p>Kocaeli, Türkiye</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user