diff --git a/data/app.db b/data/app.db
index abb867e..5cfc41e 100644
Binary files a/data/app.db and b/data/app.db differ
diff --git a/src/app/api/sosyal-kanit/route.ts b/src/app/api/sosyal-kanit/route.ts
new file mode 100644
index 0000000..5dac81d
--- /dev/null
+++ b/src/app/api/sosyal-kanit/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from "next/server";
+import { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
+
+// Fiyat bölümündeki şeridin canlı sayıyı tazelemek için yokladığı uç.
+// İlk değer sunucudan HTML ile geliyor (bkz. components/landing/sosyal-kanit.tsx);
+// burası yalnızca sayfa açık kaldıkça gelen güncellemeler için.
+// Sayılar sosyal kanıt, gizli veri değil — kimlik doğrulaması yok.
+// Yük: sosyalKanitVerisi kendi içinde TTL önbelleğine sahip, yoklama
+// sıklığı DB/Rybbit'e yansımaz.
+export async function GET() {
+ const veri = await sosyalKanitVerisi();
+ return NextResponse.json(veri, {
+ headers: { "Cache-Control": "no-store" },
+ });
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 74af976..802c830 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -16,11 +16,13 @@ import {
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
+import { Suspense } from "react";
import { HeroBaslik } from "@/components/hero-baslik";
import { KapanisCta } from "@/components/hero-focus-button";
import { HeroForm } from "@/components/hero-form";
import { Parallax } from "@/components/parallax";
import { Reveal } from "@/components/landing/reveal";
+import { SosyalKanitBandi } from "@/components/landing/sosyal-kanit";
import { TurkeyPixelMap } from "@/components/turkey-pixel-map";
import {
PixelCorner,
@@ -528,18 +530,31 @@ export default function Home() {
))}
-
-
- Temel program arama herkes için ücretsiz · iyzico güvenli ödeme
-
+
+ {/* Boşluk sarmalayıcıda: sosyal kanıt eşik altında kalıp hiç
+ çizilmediğinde buton yine listeden mt-8 uzakta durur. */}
+
+ {/* Karar butonun başında veriliyor; kanıt da orada dursun.
+ İstek anında okunuyor, o yüzden Suspense içinde. */}
+
+
+
+
+
+
+
+ Temel program arama herkes için ücretsiz · iyzico güvenli
+ ödeme
+
+
) {
+ // null = animasyon çalışmıyor → güncel hedef doğrudan gösterilir. Böylece
+ // yoklama sayıyı tazelediğinde sayaç baştan oynamaz, sadece değer değişir.
+ const [animasyon, setAnimasyon] = useState(null);
+ const hedefRef = useRef(hedef);
+
+ useEffect(() => {
+ hedefRef.current = hedef;
+ }, [hedef]);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ if (
+ typeof IntersectionObserver === "undefined" ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ return;
+ }
+
+ let cerceve = 0;
+ const io = new IntersectionObserver(
+ (girisler) => {
+ if (!girisler.some((g) => g.isIntersecting)) return;
+ io.disconnect();
+ const son = hedefRef.current;
+ const basla = performance.now();
+ const adim = (simdi: number) => {
+ const t = Math.min(1, (simdi - basla) / SAYAC_MS);
+ if (t < 1) {
+ // easeOutCubic: hızlı başlar, son sayıya yumuşak oturur
+ setAnimasyon(Math.round(son * (1 - Math.pow(1 - t, 3))));
+ cerceve = requestAnimationFrame(adim);
+ } else {
+ setAnimasyon(null);
+ }
+ };
+ setAnimasyon(0);
+ cerceve = requestAnimationFrame(adim);
+ },
+ { rootMargin: "0px 0px -60px 0px" },
+ );
+ io.observe(el);
+ return () => {
+ io.disconnect();
+ cancelAnimationFrame(cerceve);
+ };
+ }, [ref]);
+
+ return animasyon ?? hedef;
+}
+
+export function SosyalKanitGovde({ ilk }: { ilk: SosyalKanitVerisi }) {
+ const [veri, setVeri] = useState(ilk);
+ const ref = useRef(null);
+ const gosterilenListe = useSayac(veri.liste, ref);
+
+ useEffect(() => {
+ // Sekme arkadayken yoklama yok: kimse bakmıyorken sunucuyu meşgul etme.
+ let iptal = false;
+ let zamanlayici: ReturnType | undefined;
+
+ async function tazele() {
+ try {
+ const cevap = await fetch("/api/sosyal-kanit", { cache: "no-store" });
+ if (!cevap.ok) return;
+ const yeni = (await cevap.json()) as SosyalKanitVerisi;
+ if (!iptal) setVeri(yeni);
+ } catch {
+ // Sessiz: ekrandaki son sayı kalır.
+ }
+ }
+
+ function planla() {
+ clearTimeout(zamanlayici);
+ if (document.visibilityState !== "visible") return;
+ zamanlayici = setTimeout(() => {
+ void tazele().then(planla);
+ }, YOKLAMA_MS);
+ }
+
+ function gorunurlukDegisti() {
+ if (document.visibilityState === "visible") void tazele();
+ planla();
+ }
+
+ planla();
+ document.addEventListener("visibilitychange", gorunurlukDegisti);
+ return () => {
+ iptal = true;
+ clearTimeout(zamanlayici);
+ document.removeEventListener("visibilitychange", gorunurlukDegisti);
+ };
+ }, []);
+
+ if (!esikGecti(veri.liste, LISTE_ESIGI)) return null;
+
+ const canliVar = veri.canli !== null && esikGecti(veri.canli, CANLI_ESIGI);
+
+ return (
+
+
+
+ {sayi(gosterilenListe)}
+
+
+ öğrenci listesini KolayTercih ile oluşturdu
+
+
+
+ {canliVar ? (
+
+
+
+
+
+
+ şu anda{" "}
+
+ {sayi(veri.canli as number)}
+ {" "}
+ kişi KolayTercih ile tercihini planlıyor
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/landing/sosyal-kanit.tsx b/src/components/landing/sosyal-kanit.tsx
new file mode 100644
index 0000000..f31d382
--- /dev/null
+++ b/src/components/landing/sosyal-kanit.tsx
@@ -0,0 +1,17 @@
+import { SosyalKanitGovde } from "@/components/landing/sosyal-kanit-govde";
+import { LISTE_ESIGI, esikGecti } from "@/lib/sosyal-kanit-sabitler";
+import { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
+
+/**
+ * Fiyat bölümünün üstündeki sosyal kanıt şeridi: kaç öğrenci listesini
+ * oluşturdu + şu anda kaç kişi sitede.
+ *
+ * Dinamik (istek anında okunur) olduğu için çağıran tarafta bir `Suspense`
+ * sınırının içinde durmalı. Fallback bilerek boş: eşik altındayken şerit hiç
+ * çizilmiyor, yer ayıran bir iskelet o durumda boşluk bırakırdı.
+ */
+export async function SosyalKanitBandi() {
+ const veri = await sosyalKanitVerisi();
+ if (!esikGecti(veri.liste, LISTE_ESIGI)) return null;
+ return ;
+}
diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx
index a975287..756e46f 100644
--- a/src/components/site-footer.tsx
+++ b/src/components/site-footer.tsx
@@ -155,17 +155,6 @@ export function SiteFooter() {
İletişim
-
- {/* Rybbit canlı ziyaretçi rozeti — sola yaslı */}
-
-
-
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index b38d8bd..85acc9d 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -19,7 +19,7 @@ async function sendMagicLinkEmail(email: string, url: string) {
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
const { subject, html, text } = magicLinkEpostasi(url);
- await resend.emails.send({
+ const { error } = await resend.emails.send({
from: process.env.EMAIL_FROM ?? "KolayTercih ",
to: email,
subject,
@@ -28,6 +28,13 @@ async function sendMagicLinkEmail(email: string, url: string) {
// istemcilerde bağlantının okunabilir kalmasını sağlar.
text,
});
+ // Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
+ // doğrulanmamış domain/kota hatasında kullanıcı "gönderildi" ekranını
+ // görür ama mail hiç çıkmaz — fırlatarak better-auth'a hata döndürüyoruz.
+ if (error) {
+ console.error(`[giris] Magic link gönderilemedi (${email}):`, error);
+ throw new Error(`Resend: ${error.name} — ${error.message}`);
+ }
}
export const auth = betterAuth({
diff --git a/src/lib/sosyal-kanit-sabitler.ts b/src/lib/sosyal-kanit-sabitler.ts
new file mode 100644
index 0000000..3b830f4
--- /dev/null
+++ b/src/lib/sosyal-kanit-sabitler.ts
@@ -0,0 +1,33 @@
+/**
+ * Sosyal kanıt şeridinin eşikleri ve veri tipi. Ayrı dosya, çünkü şeridin
+ * istemci gövdesi de bunları okuyor; `sosyal-kanit.ts` veritabanı ve Rybbit
+ * anahtarını içeri aldığı için istemci paketine girmemeli.
+ */
+
+/**
+ * Liste sayısı bu eşiğin altındaysa şerit hiç gösterilmez: "12 öğrenci
+ * oluşturdu" satın almaya itmez, tersine yeni/boş izlenimi verir.
+ */
+export const LISTE_ESIGI = 25;
+
+/**
+ * Canlı sayı bu eşiğin altındaysa canlı satır gizlenir. 1 kişi zaten
+ * ziyaretçinin kendisi; "1 kişi sitede" sosyal kanıt değil, tam tersi.
+ */
+export const CANLI_ESIGI = 3;
+
+/**
+ * Eşikler yalnızca prod'da işler. Geliştirmede veritabanında bir avuç kayıt
+ * olur; şeridi gizlemek "kod çalışmıyor" gibi görünür, oysa çalışıyor.
+ */
+export function esikGecti(sayi: number, esik: number): boolean {
+ if (process.env.NODE_ENV !== "production") return sayi > 0;
+ return sayi >= esik;
+}
+
+export interface SosyalKanitVerisi {
+ /** Üretilmiş tercih listesi sayısı. */
+ liste: number;
+ /** Son 30 dakikadaki ziyaretçi; kaynak yoksa null. */
+ canli: number | null;
+}
diff --git a/src/lib/sosyal-kanit.ts b/src/lib/sosyal-kanit.ts
new file mode 100644
index 0000000..a5a16e4
--- /dev/null
+++ b/src/lib/sosyal-kanit.ts
@@ -0,0 +1,105 @@
+import { count } from "drizzle-orm";
+import { connection } from "next/server";
+import { appDb, schema } from "@/lib/appdb";
+import type { SosyalKanitVerisi } from "@/lib/sosyal-kanit-sabitler";
+
+/**
+ * Fiyat bölümündeki sosyal kanıt şeridinin sayıları.
+ *
+ * İki sayı, iki ayrı kaynak — bilerek:
+ *
+ * 1. "X öğrenci listesini oluşturdu" KENDİ veritabanımızdan gelir (`reports`
+ * tablosu; satır yalnızca liste gerçekten üretildikten sonra yazılır,
+ * bkz. app/sonuc/actions.ts). Bu sayı ziyaretçiye verilen bir söz olduğu
+ * için gerçek olmak zorunda; Rybbit'ten okunsaydı reklam engelleyiciler
+ * yüzünden olduğundan düşük çıkardı.
+ * 2. "Şu anda X kişi sitede" yalnızca Rybbit'ten gelebilir: giriş yapmamış
+ * ziyaretçinin bizim veritabanımızda hiçbir izi yok.
+ *
+ * Rybbit env'i eksikse (dev, anahtar tanımsız) canlı sayı `null` döner ve
+ * şeritte o satır hiç çizilmez — asla uydurulmuş sayı gösterilmez.
+ */
+
+/** Rybbit'in "şu an sitede" penceresi (GA'nın 30 dakikalık tanımıyla aynı). */
+const CANLI_DAKIKA = 30;
+
+const LISTE_TTL_MS = 10 * 60_000;
+const CANLI_TTL_MS = 30_000;
+
+const HOST = process.env.RYBBIT_HOST;
+const API_KEY = process.env.RYBBIT_API_KEY;
+/**
+ * DİKKAT: Rybbit'in istatistik API'si SAYISAL site kimliği ister
+ * (`/api/sites/:siteId/...` sorgusu ClickHouse'ta `site_id` Int32'ye
+ * bağlanıyor). `RYBBIT_SITE_ID` ise script'in kullandığı herkese açık
+ * kimlik (f2cb...) — ikisi farklı, birbirinin yerine geçmez.
+ */
+const STATS_SITE_ID = process.env.RYBBIT_STATS_SITE_ID;
+
+type Kutu = { deger: T; zaman: number };
+
+/**
+ * Süreç içi TTL önbelleği. `use cache` yerine bunu kullanıyoruz: derleme
+ * anında `data/` boş (bkz. Dockerfile), dolayısıyla prerender edilen bir
+ * sayı 0 olarak taşa yazılırdı. `connection()` + bu bellek, sayıyı istek
+ * anına taşır ama her ziyaretçide DB/Rybbit'e gitmez. Uygulama tek süreçli
+ * standalone olarak çalıştığı için modül belleği yeterli.
+ */
+const bellek: { liste?: Kutu; canli?: Kutu } = {};
+
+/** Üretilmiş tercih listesi sayısı. DB okunamazsa son bilinen değer korunur. */
+export async function olusturulanListeSayisi(): Promise {
+ await connection();
+
+ const onceki = bellek.liste;
+ if (onceki && Date.now() - onceki.zaman < LISTE_TTL_MS) return onceki.deger;
+
+ let deger = onceki?.deger ?? 0;
+ try {
+ const [satir] = await appDb.select({ n: count() }).from(schema.reports);
+ deger = satir?.n ?? 0;
+ } catch {
+ // Sayı gösterilemezse şerit gizlenir; sayfa asla hata vermez.
+ }
+ bellek.liste = { deger, zaman: Date.now() };
+ return deger;
+}
+
+/** Son 30 dakikadaki tekil ziyaretçi. Rybbit yoksa/erişilemezse null. */
+export async function anlikZiyaretci(): Promise {
+ if (!HOST || !API_KEY || !STATS_SITE_ID) return null;
+
+ const onceki = bellek.canli;
+ if (onceki && Date.now() - onceki.zaman < CANLI_TTL_MS) return onceki.deger;
+
+ let deger: number | null = null;
+ try {
+ const cevap = await fetch(
+ `${HOST}/api/sites/${STATS_SITE_ID}/live-user-count?minutes=${CANLI_DAKIKA}`,
+ {
+ headers: { Authorization: `Bearer ${API_KEY}` },
+ cache: "no-store",
+ // Analitik hiçbir koşulda sayfayı bekletmesin
+ signal: AbortSignal.timeout(3000),
+ },
+ );
+ if (cevap.ok) {
+ const veri = (await cevap.json()) as { count?: number };
+ if (typeof veri.count === "number" && Number.isFinite(veri.count)) {
+ deger = veri.count;
+ }
+ }
+ } catch {
+ // Yut: canlı satır çizilmez, şeridin geri kalanı ayakta kalır.
+ }
+ bellek.canli = { deger, zaman: Date.now() };
+ return deger;
+}
+
+export async function sosyalKanitVerisi(): Promise {
+ const [liste, canli] = await Promise.all([
+ olusturulanListeSayisi(),
+ anlikZiyaretci(),
+ ]);
+ return { liste, canli };
+}