304a344955
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).
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|