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:
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
15
src/app/api/sosyal-kanit/route.ts
Normal file
15
src/app/api/sosyal-kanit/route.ts
Normal file
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,11 +16,13 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { HeroBaslik } from "@/components/hero-baslik";
|
import { HeroBaslik } from "@/components/hero-baslik";
|
||||||
import { KapanisCta } from "@/components/hero-focus-button";
|
import { KapanisCta } from "@/components/hero-focus-button";
|
||||||
import { HeroForm } from "@/components/hero-form";
|
import { HeroForm } from "@/components/hero-form";
|
||||||
import { Parallax } from "@/components/parallax";
|
import { Parallax } from "@/components/parallax";
|
||||||
import { Reveal } from "@/components/landing/reveal";
|
import { Reveal } from "@/components/landing/reveal";
|
||||||
|
import { SosyalKanitBandi } from "@/components/landing/sosyal-kanit";
|
||||||
import { TurkeyPixelMap } from "@/components/turkey-pixel-map";
|
import { TurkeyPixelMap } from "@/components/turkey-pixel-map";
|
||||||
import {
|
import {
|
||||||
PixelCorner,
|
PixelCorner,
|
||||||
@@ -528,18 +530,31 @@ export default function Home() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{/* Boşluk sarmalayıcıda: sosyal kanıt eşik altında kalıp hiç
|
||||||
|
çizilmediğinde buton yine listeden mt-8 uzakta durur. */}
|
||||||
|
<div className="mt-8 flex flex-col gap-4">
|
||||||
|
{/* Karar butonun başında veriliyor; kanıt da orada dursun.
|
||||||
|
İstek anında okunuyor, o yüzden Suspense içinde. */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SosyalKanitBandi />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
className="mt-8 h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
<Link href="/paket">
|
<Link href="/paket">
|
||||||
{PAKET_FIYATI} TL — Paketi incele
|
{PAKET_FIYATI} TL — Paketi incele
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<p className="mt-4 text-center text-xs text-slate-500">
|
|
||||||
Temel program arama herkes için ücretsiz · iyzico güvenli ödeme
|
<p className="text-center text-xs text-slate-500">
|
||||||
|
Temel program arama herkes için ücretsiz · iyzico güvenli
|
||||||
|
ödeme
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal
|
<Reveal
|
||||||
|
|||||||
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
|
İletişim
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async function sendMagicLinkEmail(email: string, url: string) {
|
|||||||
const { Resend } = await import("resend");
|
const { Resend } = await import("resend");
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
const { subject, html, text } = magicLinkEpostasi(url);
|
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>",
|
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
@@ -28,6 +28,13 @@ async function sendMagicLinkEmail(email: string, url: string) {
|
|||||||
// istemcilerde bağlantının okunabilir kalmasını sağlar.
|
// istemcilerde bağlantının okunabilir kalmasını sağlar.
|
||||||
text,
|
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({
|
export const auth = betterAuth({
|
||||||
|
|||||||
33
src/lib/sosyal-kanit-sabitler.ts
Normal file
33
src/lib/sosyal-kanit-sabitler.ts
Normal 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
105
src/lib/sosyal-kanit.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user