Files
kovakyazilim/components/cookie-banner.tsx
T
Ege Can Komur 304a344955 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).
2026-05-20 03:27:02 +03:00

322 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}