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
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:
155
src/components/landing/sosyal-kanit-govde.tsx
Normal file
155
src/components/landing/sosyal-kanit-govde.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/landing/sosyal-kanit.tsx
Normal file
17
src/components/landing/sosyal-kanit.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user