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

@@ -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 <onboarding@resend.dev>",
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({

View File

@@ -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;
}

105
src/lib/sosyal-kanit.ts Normal file
View File

@@ -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<T> = { 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<number>; canli?: Kutu<number | null> } = {};
/** Üretilmiş tercih listesi sayısı. DB okunamazsa son bilinen değer korunur. */
export async function olusturulanListeSayisi(): Promise<number> {
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<number | null> {
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<SosyalKanitVerisi> {
const [liste, canli] = await Promise.all([
olusturulanListeSayisi(),
anlikZiyaretci(),
]);
return { liste, canli };
}