diff --git a/src/app/cerez-politikasi/page.tsx b/src/app/cerez-politikasi/page.tsx
index 85b40f6..128614f 100644
--- a/src/app/cerez-politikasi/page.tsx
+++ b/src/app/cerez-politikasi/page.tsx
@@ -32,7 +32,7 @@ export default function CerezPolitikasiPage() {
Çerez Politikası
- Son güncelleme: 5 Ekim 2026
+ Son güncelleme: 11 Ekim 2026
@@ -117,7 +117,11 @@ export default function CerezPolitikasiPage() {
Paket satın alırken kart formunu iyzico gösterir ve ödeme
güvenliği için kendi çerezlerini kullanabilir. Google ile giriş
yaparsan Google’ın sayfasına yönlendirilirsin; orada
- Google’ın çerez politikası geçerlidir. Bunların dışında
+ Google’ın çerez politikası geçerlidir. Geri bildirim
+ kutusuna yazmaya başladığında otomatik gönderimleri ayırt etmek
+ için Cloudflare Turnstile’ın doğrulama betiği yüklenir; bu
+ betik seni siteler arasında izlemez ve reklam amacıyla
+ kullanılmaz. Bunların dışında
sitede başka şirketlere ait çerez, reklam ya da sosyal medya
eklentisi yoktur.
diff --git a/src/app/gizlilik/page.tsx b/src/app/gizlilik/page.tsx
index 52ab90a..a851af6 100644
--- a/src/app/gizlilik/page.tsx
+++ b/src/app/gizlilik/page.tsx
@@ -30,7 +30,7 @@ export default function GizlilikPage() {
Gizlilik ve Kişisel Verilerin Korunması
- Son güncelleme: 5 Ekim 2026
+ Son güncelleme: 11 Ekim 2026
@@ -179,10 +179,19 @@ export default function GizlilikPage() {
programlar ve kaydettiğin tercihler. Senin yazdığın mesaj, adın
ve e-postan gönderilmez.
+
+ Cloudflare Turnstile (ABD) — geri bildirim
+ kutusunu otomatik gönderimlerden korur. Kutuya yazmaya
+ başladığında Cloudflare’in doğrulama betiği yüklenir; IP
+ adresin ile tarayıcı ve cihaz bilgilerin, karşıdakinin insan
+ olup olmadığının anlaşılması için Cloudflare’e iletilir.
+ Yazdığın mesaj Cloudflare’e gönderilmez.
+
- KVKK açısından e-posta sağlayıcısına, yapay zekâ sağlayıcısına ve
- cevap denetimi sağlayıcısına yapılan iletim yurt dışına
+ KVKK açısından e-posta sağlayıcısına, yapay zekâ sağlayıcısına,
+ cevap denetimi sağlayıcısına ve bot koruması sağlayıcısına
+ yapılan iletim yurt dışına
aktarımdır ve hizmetin sunulabilmesi için gereklidir.
Sağlayıcı değişirse bu bölüm aynı gün güncellenir.
diff --git a/src/features/geri-bildirim/components/geri-bildirim-formu.tsx b/src/features/geri-bildirim/components/geri-bildirim-formu.tsx
index af72c78..9715952 100644
--- a/src/features/geri-bildirim/components/geri-bildirim-formu.tsx
+++ b/src/features/geri-bildirim/components/geri-bildirim-formu.tsx
@@ -2,28 +2,112 @@
// Sürüm notları sayfasının altındaki geri bildirim kutusu: başlık, metin
// alanı, buton. Mesaj geriBildirimGonder sunucu aksiyonuyla destek kutusuna
-// e-posta olarak gider.
+// e-posta olarak gider. Bot koruması Cloudflare Turnstile: betik ancak metin
+// alanına ilk odaklanıldığında yüklenir, kutusu yalnız Cloudflare elle onay
+// istediğinde görünür (çoğu ziyaretçi hiç görmez).
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { ArrowRight } from "lucide-react";
import { toast } from "@/components/ui/bildirim";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { TypingAnimation } from "@/components/ui/typing-animation";
-import { geriBildirimGonder } from "@/features/geri-bildirim/geri-bildirim-actions";
+import {
+ geriBildirimGonder,
+ geriBildirimKorumasi,
+} from "@/features/geri-bildirim/geri-bildirim-actions";
const BASLIK = "Bir hata mı gördün?";
const DEVAM = "Yalın üretim metodunu benimsiyor ve fikirlerine önem veriyoruz.";
+const TURNSTILE_BETIGI =
+ "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
+
+type Turnstile = {
+ render: (kap: HTMLElement, ayar: Record) => string;
+ reset: (kimlik: string) => void;
+ remove: (kimlik: string) => void;
+};
+
+declare global {
+ interface Window {
+ turnstile?: Turnstile;
+ }
+}
+
+let betikSozu: Promise | null = null;
+
+function turnstileBetiginiYukle(): Promise {
+ betikSozu ??= new Promise((coz, reddet) => {
+ const betik = document.createElement("script");
+ betik.src = TURNSTILE_BETIGI;
+ betik.async = true;
+ betik.onload = () => coz();
+ betik.onerror = () => {
+ betikSozu = null;
+ reddet(new Error("Turnstile betiği yüklenemedi"));
+ };
+ document.head.appendChild(betik);
+ });
+ return betikSozu;
+}
+
export function GeriBildirimFormu({ className = "" }: { className?: string }) {
const [mesaj, setMesaj] = useState("");
const [pending, setPending] = useState(false);
+ // Koruma açık ve token henüz gelmediyse buton bekler. Betik yüklenemezse
+ // (engelleyici, ağ) korumaAcik false kalır; son sözü sunucu söyler.
+ const [korumaAcik, setKorumaAcik] = useState(false);
+ const [token, setToken] = useState(null);
+ const [onayGorunur, setOnayGorunur] = useState(false);
+ const korumaKabi = useRef(null);
+ const widget = useRef(null);
+ const korumaBasladi = useRef(false);
+
+ async function korumayiBaslat() {
+ if (korumaBasladi.current) return;
+ korumaBasladi.current = true;
+ try {
+ const { siteAnahtari } = await geriBildirimKorumasi();
+ if (!siteAnahtari) return;
+ await turnstileBetiginiYukle();
+ if (!korumaKabi.current || !window.turnstile) return;
+ widget.current = window.turnstile.render(korumaKabi.current, {
+ sitekey: siteAnahtari,
+ appearance: "interaction-only",
+ size: "flexible",
+ language: "tr",
+ theme: document.documentElement.classList.contains("dark")
+ ? "dark"
+ : "light",
+ callback: (yeni: string) => setToken(yeni),
+ "before-interactive-callback": () => setOnayGorunur(true),
+ "after-interactive-callback": () => setOnayGorunur(false),
+ "expired-callback": () => setToken(null),
+ "error-callback": () => setToken(null),
+ });
+ setKorumaAcik(true);
+ } catch {
+ korumaBasladi.current = false;
+ }
+ }
+
+ useEffect(() => {
+ return () => {
+ if (widget.current) window.turnstile?.remove(widget.current);
+ };
+ }, []);
async function gonder(e: React.FormEvent) {
e.preventDefault();
setPending(true);
try {
- const sonuc = await geriBildirimGonder(mesaj);
+ const sonuc = await geriBildirimGonder(mesaj, token);
+ // Token tek kullanımlık: sonuç ne olursa olsun yenisi istenir.
+ if (widget.current) {
+ setToken(null);
+ window.turnstile?.reset(widget.current);
+ }
if (sonuc.ok) {
toast.success("Geri bildirimin ulaştı, teşekkürler.");
setMesaj("");
@@ -73,17 +157,23 @@ export function GeriBildirimFormu({ className = "" }: { className?: string }) {
aria-labelledby="geri-bildirim-baslik"
value={mesaj}
onChange={(e) => setMesaj(e.target.value)}
+ onFocus={korumayiBaslat}
maxLength={2000}
placeholder="Çekinme, aklına geleni yaz…"
disabled={pending}
className="min-h-28 resize-none border-0 bg-transparent px-3 py-2 focus-visible:ring-0 disabled:bg-transparent"
/>
+
{pending ? "Gönderiliyor…" : "Gönder"}
diff --git a/src/features/geri-bildirim/geri-bildirim-actions.ts b/src/features/geri-bildirim/geri-bildirim-actions.ts
index a89306b..69ccbc1 100644
--- a/src/features/geri-bildirim/geri-bildirim-actions.ts
+++ b/src/features/geri-bildirim/geri-bildirim-actions.ts
@@ -5,6 +5,7 @@ import { getSession } from "@/lib/session";
import { geriBildirimEpostasi } from "@/lib/eposta";
import { epostaGonder } from "@/lib/eposta-gonder";
import { SURUM } from "@/lib/surum";
+import { turnstileDogrula, turnstileSiteAnahtari } from "@/lib/turnstile";
const DESTEK_ADRESI = "destek@kolaytercih.com";
const MIN_UZUNLUK = 10;
@@ -39,8 +40,19 @@ function sinirAsildi(ip: string): boolean {
return false;
}
+/**
+ * Formun bot korumasını (Turnstile) kurması için site anahtarı. Koruma
+ * kapalıysa null döner ve form korumasız çalışır.
+ */
+export async function geriBildirimKorumasi(): Promise<{
+ siteAnahtari: string | null;
+}> {
+ return { siteAnahtari: turnstileSiteAnahtari() };
+}
+
export async function geriBildirimGonder(
hamMesaj: string,
+ korumaTokeni?: string | null,
): Promise<{ ok: true } | { ok: false; error: string }> {
const mesaj = typeof hamMesaj === "string" ? hamMesaj.trim() : "";
if (mesaj.length < MIN_UZUNLUK) {
@@ -61,6 +73,17 @@ export async function geriBildirimGonder(
error: "Kısa sürede çok mesaj gönderdin. Birkaç dakika sonra tekrar dene.",
};
}
+ const insan = await turnstileDogrula(
+ typeof korumaTokeni === "string" ? korumaTokeni : null,
+ ip === "bilinmiyor" ? null : ip,
+ );
+ if (!insan) {
+ return {
+ ok: false,
+ error:
+ "Güvenlik doğrulaması tamamlanamadı. Sayfayı yenileyip tekrar dener misin?",
+ };
+ }
try {
await epostaGonder(
diff --git a/src/lib/turnstile.ts b/src/lib/turnstile.ts
new file mode 100644
index 0000000..371a310
--- /dev/null
+++ b/src/lib/turnstile.ts
@@ -0,0 +1,62 @@
+import "server-only";
+
+/**
+ * Cloudflare Turnstile (bot koruması) — sunucu tarafı. Formda üretilen token
+ * burada Cloudflare'e doğrulatılır; tarayıcının "geçtim" demesine güvenilmez.
+ *
+ * Anahtarlar public önekli DEĞİL ve çağrı anında okunur (bkz. app-url.ts):
+ * site anahtarı da tarayıcıya sunucu aksiyonuyla gider, build'e gömülmez.
+ * İki anahtardan biri eksikse koruma kapalıdır — form anahtarsız ortamda
+ * (lokal, anahtar girilmeden yapılan deploy) çalışmaya devam eder.
+ */
+
+const DOGRULAMA_UCU =
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify";
+
+/** Koruma açıksa tarayıcıya verilecek site anahtarı, kapalıysa null. */
+export function turnstileSiteAnahtari(): string | null {
+ const site = process.env.TURNSTILE_SITE_KEY;
+ return site && process.env.TURNSTILE_SECRET_KEY ? site : null;
+}
+
+/**
+ * Token geçerliyse (ya da koruma kapalıysa) true. Cloudflare'e ulaşılamazsa
+ * false döner: doğrulanamayan istek geçmiş sayılmaz.
+ */
+export async function turnstileDogrula(
+ token: string | null | undefined,
+ ip: string | null,
+): Promise {
+ if (!turnstileSiteAnahtari()) {
+ if (process.env.NODE_ENV === "production") {
+ console.warn("[turnstile] Anahtarlar tanımlı değil, koruma kapalı");
+ }
+ return true;
+ }
+ if (!token) return false;
+
+ const govde = new URLSearchParams({
+ secret: process.env.TURNSTILE_SECRET_KEY!,
+ response: token,
+ });
+ if (ip) govde.set("remoteip", ip);
+
+ try {
+ const yanit = await fetch(DOGRULAMA_UCU, {
+ method: "POST",
+ body: govde,
+ signal: AbortSignal.timeout(5000),
+ });
+ const sonuc = (await yanit.json()) as {
+ success: boolean;
+ "error-codes"?: string[];
+ };
+ if (!sonuc.success) {
+ console.warn("[turnstile] Reddedildi:", sonuc["error-codes"]);
+ }
+ return sonuc.success;
+ } catch (hata) {
+ console.error("[turnstile] Doğrulama ucuna ulaşılamadı:", hata);
+ return false;
+ }
+}