Update app.db, enhance Home component with social proof feature, and improve error handling in auth module
All checks were successful
Deploy / deploy (push) Successful in 22m12s

- Updated app.db to reflect recent changes.
- Added a Suspense-wrapped SosyalKanitBandi component in the Home page for improved user engagement.
- Refactored the sendMagicLinkEmail function in auth.ts to handle errors more effectively, ensuring users receive appropriate feedback on email sending issues.
- Removed the Rybbit live visitor widget from the site footer for a cleaner design.
This commit is contained in:
bilalgursen
2026-08-02 21:10:43 +03:00
parent 0b92f999bc
commit 2278d2a218
9 changed files with 360 additions and 24 deletions

View File

@@ -0,0 +1,155 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
CANLI_ESIGI,
LISTE_ESIGI,
esikGecti,
type SosyalKanitVerisi,
} from "@/lib/sosyal-kanit-sabitler";
/** Sayfa açık kaldıkça canlı sayıyı tazeleyen yoklama aralığı. */
const YOKLAMA_MS = 60_000;
/** Sayacın son değere ulaşma süresi. */
const SAYAC_MS = 900;
function sayi(n: number): string {
return n.toLocaleString("tr-TR");
}
/**
* Liste sayısı görünür olunca son değere kadar sayar. SSR HTML'i son değeri
* taşır (JS'siz istemcide sayı doğru görünür); animasyon yalnızca hareket
* isteyen istemcide, üstelik yalnızca bir kez çalışır.
*/
function useSayac(hedef: number, ref: React.RefObject<HTMLElement | null>) {
// 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<number | null>(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<HTMLDivElement>(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<typeof setTimeout> | 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 (
<div
ref={ref}
className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-4 py-3.5"
>
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-heading text-3xl font-bold tabular-nums leading-none text-slate-900">
{sayi(gosterilenListe)}
</span>
<span className="text-sm leading-snug text-slate-600">
öğrenci listesini KolayTercih ile oluşturdu
</span>
</p>
{canliVar ? (
<p className="mt-3 flex items-center gap-2 border-t border-slate-200/70 pt-3 text-sm text-slate-600">
<span className="relative flex size-2.5 shrink-0">
<span className="absolute inline-flex size-full rounded-full bg-emerald-400 opacity-70 motion-safe:animate-ping" />
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
</span>
<span>
şu anda{" "}
<strong className="font-semibold tabular-nums text-slate-900">
{sayi(veri.canli as number)}
</strong>{" "}
kişi KolayTercih ile tercihini planlıyor
</span>
</p>
) : null}
</div>
);
}

View File

@@ -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 <SosyalKanitGovde ilk={veri} />;
}

View File

@@ -155,17 +155,6 @@ export function SiteFooter() {
İletişim
</Link>
</nav>
{/* Rybbit canlı ziyaretçi rozeti — sola yaslı */}
<div className="mt-6 flex justify-start">
<iframe
src="https://rybbit.kolaytercih.com/widget/f2cb042c657e?variant=inline&theme=dark"
style={{ border: 0, width: 220, height: 36 }}
loading="lazy"
title="Canlı ziyaretçi"
scrolling="no"
/>
</div>
</div>
</div>
</footer>