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
+138
View File
@@ -0,0 +1,138 @@
import type { Metadata } from "next";
import { SectionTitle } from "@/components/section-title";
import { CookieSettingsButton } from "@/components/cookie-settings-button";
import { getSiteSettings } from "@/lib/data";
import { siteConfig } from "@/lib/site-config";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/cerez-politikasi", {
title: "Çerez Politikası",
description:
"Kovak Yazılım çerez politikası — KVKK ve GDPR uyumlu çerez kullanımı.",
});
}
const CATEGORIES = [
{
title: "Zorunlu çerezler",
description:
"Web sitesinin temel işlevleri için gereklidir. Oturum yönetimi, güvenlik, form gönderimi gibi temel işlemler bu çerezler olmadan çalışamaz. Devre dışı bırakılamaz.",
examples: ["kovak_session", "kovak_consent_v1"],
},
{
title: "Analitik çerezler",
description:
"Web sitesi ziyaretçilerinin sitenizi nasıl kullandığını anlamamıza yardımcı olur. Toplanan veriler anonimdir ve istatistiksel amaçlıdır.",
examples: ["_ga", "_gid", "_gat"],
},
{
title: "Reklam çerezleri",
description:
"Sizinle ilgili olabilecek reklamların gösterilmesi ve reklam kampanyalarının performansının ölçülmesi amacıyla kullanılır.",
examples: ["_gcl_au", "_fbp", "IDE", "ads/ga-audiences"],
},
{
title: "Tercih çerezleri",
description:
"Dil, bölge veya görünüm tercihlerinizi hatırlamak için kullanılır.",
examples: ["NID", "lang", "theme"],
},
];
export default async function CookiePolicyPage() {
const s = await getSiteSettings();
const email = s?.contact_email ?? siteConfig.contact.email;
const phone = s?.contact_phone ?? siteConfig.contact.phone;
return (
<div className="mx-auto max-w-3xl px-6 py-20">
<SectionTitle
align="left"
eyebrow="Yasal"
title="Çerez Politikası"
description={`Son güncelleme: ${new Date().toLocaleDateString("tr-TR")}`}
/>
<div className="mt-8 flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--sky)]/30 bg-[var(--sky-50)] p-4">
<p className="flex-1 text-sm text-[var(--navy)]">
Çerez tercihlerinizi istediğiniz zaman değiştirebilirsiniz.
</p>
<CookieSettingsButton />
</div>
<article className="prose prose-base mt-10 max-w-none text-[var(--foreground)]">
<h2>Çerez Nedir?</h2>
<p>
Çerezler (cookies), ziyaret ettiğiniz web siteleri tarafından
tarayıcınız aracılığıyla cihazınıza kaydedilen küçük metin
dosyalarıdır. Bu dosyalar sayesinde site, sizi sonraki ziyaretlerde
tanır ve deneyiminizi kişiselleştirir.
</p>
<h2>Hangi Çerezleri Kullanıyoruz?</h2>
<p>
Sitemizde 4 farklı kategoride çerez kullanılmaktadır. Zorunlu çerezler
dışındakileri çerez ayarları üzerinden devre dışı bırakabilirsiniz.
</p>
{CATEGORIES.map((c) => (
<section key={c.title} className="not-prose mt-6 rounded-xl border border-[var(--border)] bg-white p-5">
<h3 className="text-base font-semibold text-[var(--navy)]">
{c.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">
{c.description}
</p>
<p className="mt-3 text-xs text-[var(--muted)]">
<strong>Örnek çerezler:</strong>{" "}
{c.examples.map((e, i) => (
<span key={e}>
<code className="rounded bg-[var(--navy-50)] px-1.5 py-0.5 text-[var(--navy)]">
{e}
</code>
{i < c.examples.length - 1 && ", "}
</span>
))}
</p>
</section>
))}
<h2 className="mt-10">Çerezleri Nasıl Devre Dışı Bırakabilirim?</h2>
<p>
Tarayıcı ayarlarınızdan tüm çerezleri silebilir veya engelleyebilirsiniz.
Ancak zorunlu çerezler devre dışı bırakıldığında sitemizin bazı bölümleri
çalışmayabilir. Tercihlerinizi yönetmek için sayfanın altındaki "Çerez
ayarları" butonunu kullanabilirsiniz.
</p>
<h2>KVKK Kapsamında Haklarınız</h2>
<p>
6698 sayılı Kişisel Verilerin Korunması Kanunu (KVKK) kapsamında
aşağıdaki haklara sahipsiniz:
</p>
<ul>
<li>Kişisel verilerinizin işlenip işlenmediğini öğrenme</li>
<li>İşlenmişse buna ilişkin bilgi talep etme</li>
<li>İşlenme amacını ve amacına uygun kullanılıp kullanılmadığını öğrenme</li>
<li>Eksik veya yanlış işlenmiş verilerin düzeltilmesini isteme</li>
<li>Silinmesini veya yok edilmesini isteme</li>
<li>Kanuna aykırı işleme sonucu zarara uğramışsanız tazminat talep etme</li>
</ul>
<h2>İletişim</h2>
<p>
Çerez politikamız veya kişisel verilerinizin işlenmesi hakkında soru
veya talepleriniz için bizimle iletişime geçebilirsiniz:
</p>
<ul>
<li>
E-posta: <a href={`mailto:${email}`}>{email}</a>
</li>
<li>Telefon: {phone}</li>
<li>Adres: {s?.contact_address ?? siteConfig.contact.address}</li>
</ul>
</article>
</div>
);
}
+16 -1
View File
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { ConsentInit } from "@/components/consent-init";
import { CookieBanner } from "@/components/cookie-banner";
import { getSeoSettings } from "@/lib/data";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -29,16 +32,28 @@ export const metadata: Metadata = {
icons: { icon: "/logo.png" }, icons: { icon: "/logo.png" },
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
let gtmId: string | null = null;
try {
const seo = await getSeoSettings();
gtmId = seo?.gtm_id ?? null;
} catch {
gtmId = null;
}
return ( return (
<html <html
lang="tr" lang="tr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<head>
<ConsentInit gtmId={gtmId} />
</head>
<body className="min-h-full flex flex-col bg-white text-[var(--foreground)]"> <body className="min-h-full flex flex-col bg-white text-[var(--foreground)]">
{children} {children}
<CookieBanner />
</body> </body>
</html> </html>
); );
+52
View File
@@ -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>
</>
)}
</>
);
}
+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>
);
}
+16
View File
@@ -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>
);
}
+6
View File
@@ -112,9 +112,15 @@ export async function Footer() {
<p> <p>
© {new Date().getFullYear()} {siteConfig.name}. Tüm hakları saklıdır. © {new Date().getFullYear()} {siteConfig.name}. Tüm hakları saklıdır.
</p> </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> <p>Kocaeli, Türkiye</p>
</div> </div>
</div> </div>
</div>
</footer> </footer>
); );
} }
+19
View File
@@ -0,0 +1,19 @@
export type ConsentCategory =
| "necessary"
| "analytics"
| "marketing"
| "preferences";
export interface ConsentState {
necessary: true; // her zaman
analytics: boolean;
marketing: boolean;
preferences: boolean;
timestamp: number;
version: number;
}
export const CONSENT_VERSION = 1;
export const CONSENT_STORAGE_KEY = "kovak_consent_v1";
export const CONSENT_COOKIE_NAME = "kovak_consent";
export const CONSENT_TTL_DAYS = 365;