perf: sunucu waterfall'ları ve blocking analitik düzeltildi (Faz 1)

Vercel React Best Practices denetimi, sunucu tarafı:
- odeme: analitik olayı after() ile yanıt sonrasına alındı; ucuz ürün
  doğrulaması oturum turundan öne çekildi; sonuç kartında oturum+sipariş
  paralel okunuyor
- /api/soru POST: gövde+oturum ve 3 bağımsız sorgu Promise.all'a bağlandı
- rehber: prod'da globalThis cache + özetler için frontmatter fast-path
  (24 dosya okuma + marked.parse her render'da tekrarlanıyordu); arama
  kutusuna giden payload RehberAramaKaydi'ya kırpıldı (tarih serileşmiyor)
- /sonuc hattı: ProgramListeSatiri (fakulte/puan2025 client'a serileşmiyor)
- katalog: universite/bolum listelerinde O(n²) filtreler tek geçişe indi
- sira-gerekli + katalog-arama: effect'te setState kalktı (lint yeşil)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 13:48:27 +03:00
parent d2732828cc
commit 53edb2b307
15 changed files with 256 additions and 99 deletions

View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { dilimAra } from "@/lib/db"; import { dilimAra } from "@/lib/db";
import { programOzetle } from "@/features/liste/liste-queries";
import { import {
PUAN_TURLERI, PUAN_TURLERI,
type DilimKey, type DilimKey,
@@ -39,5 +40,6 @@ export function GET(request: NextRequest) {
limit: SAYFA_BOYU, limit: SAYFA_BOYU,
offset, offset,
}); });
return NextResponse.json({ programlar, toplam }); // Tablo satırının kullanmadığı alanlar payload'a girmesin
return NextResponse.json({ programlar: programlar.map(programOzetle), toplam });
} }

View File

@@ -106,17 +106,19 @@ export async function GET() {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await getSession(); // Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı
// kalır (önce 401, sonra 400).
const [session, hamBody] = await Promise.all([
getSession(),
request.json().catch(() => null),
]);
if (!session) { if (!session) {
return Response.json({ error: "Oturum gerekli." }, { status: 401 }); return Response.json({ error: "Oturum gerekli." }, { status: 401 });
} }
if (hamBody === null || typeof hamBody !== "object") {
let body: { message?: unknown; clientMessageId?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: "Geçersiz istek." }, { status: 400 }); return Response.json({ error: "Geçersiz istek." }, { status: 400 });
} }
const body = hamBody as { message?: unknown; clientMessageId?: unknown };
const mesaj = typeof body.message === "string" ? body.message.trim() : ""; const mesaj = typeof body.message === "string" ? body.message.trim() : "";
const clientMessageId = const clientMessageId =
typeof body.clientMessageId === "string" ? body.clientMessageId : ""; typeof body.clientMessageId === "string" ? body.clientMessageId : "";
@@ -124,24 +126,30 @@ export async function POST(request: NextRequest) {
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 }); return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
} }
// Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel.
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
const [rapor, gecmisHam, kullanici] = await Promise.all([
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz) // Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
const rapor = await kullanicininRaporu(session.user.id); kullanicininRaporu(session.user.id),
appDb
.select()
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(desc(schema.chatMessages.createdAt))
.limit(MAX_GECMIS),
appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
}),
]);
if (!rapor?.result) { if (!rapor?.result) {
return Response.json( return Response.json(
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" }, { error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
{ status: 409 }, { status: 409 },
); );
} }
const gecmis = gecmisHam.reverse();
// Geçmişi krediyi düşmeden ÖNCE oku ki kullanıcı mesajı dahil olmasın
const gecmis = (
await appDb
.select()
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(desc(schema.chatMessages.createdAt))
.limit(MAX_GECMIS)
).reverse();
// Kredi düşümü stream'den önce — yetersizse hiç başlama // Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = await spendCreditForMessage({ const harcama = await spendCreditForMessage({
@@ -166,10 +174,6 @@ export async function POST(request: NextRequest) {
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi // Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
// halde danışman kilitli listeyi sözle sızdırabilir. // halde danışman kilitli listeyi sözle sızdırabilir.
const kullanici = await appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
});
const kilitli = !kullanici?.hasPaket; const kilitli = !kullanici?.hasPaket;
const tamOzet = listeOzetiCikar( const tamOzet = listeOzetiCikar(
rapor.result as RaporSonuc, rapor.result as RaporSonuc,

View File

@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { KatalogArama } from "@/features/katalog/components/katalog-arama"; import { KatalogArama } from "@/features/katalog/components/katalog-arama";
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav"; import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
import { getAllRehberler } from "@/lib/rehber"; import { getRehberAramaKayitlari } from "@/lib/rehber";
export function SiteHeader() { export function SiteHeader() {
return ( return (
@@ -32,7 +32,7 @@ export function SiteHeader() {
</span> </span>
</span> </span>
</Link> </Link>
<KatalogArama rehberler={getAllRehberler()} /> <KatalogArama rehberler={getRehberAramaKayitlari()} />
{/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */} {/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */}
<Suspense fallback={<UserNavFallback />}> <Suspense fallback={<UserNavFallback />}>
<UserNav /> <UserNav />

View File

@@ -34,15 +34,26 @@ function KategoriGruplari({
bolumler: BolumOzet[]; bolumler: BolumOzet[];
seviye: "lisans" | "onlisans"; seviye: "lisans" | "onlisans";
}) { }) {
const kalanlar = new Set(bolumler.map((b) => b.slug)); // Tek geçişte kategori index'i — kategori×bölüm çapraz filter yerine.
const gruplar = KATEGORILER.map((kategori) => { // Bir bölüm birden çok kategoride görünebilir; hiçbirinde yoksa "Diğer"e düşer.
const uyeler = bolumler.filter((b) => const uyeMap = new Map<string, BolumOzet[]>();
(b.kategoriler as readonly string[]).includes(kategori), const diger: BolumOzet[] = [];
); const gecerli = new Set<string>(KATEGORILER);
for (const b of uyeler) kalanlar.delete(b.slug); for (const b of bolumler) {
return { kategori: kategori as string, uyeler }; let eslesti = false;
}).filter((g) => g.uyeler.length > 0); for (const kategori of b.kategoriler as readonly string[]) {
const diger = bolumler.filter((b) => kalanlar.has(b.slug)); if (!gecerli.has(kategori)) continue;
const grup = uyeMap.get(kategori);
if (grup) grup.push(b);
else uyeMap.set(kategori, [b]);
eslesti = true;
}
if (!eslesti) diger.push(b);
}
const gruplar = KATEGORILER.filter((k) => uyeMap.has(k)).map((kategori) => ({
kategori: kategori as string,
uyeler: uyeMap.get(kategori)!,
}));
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger }); if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
return ( return (

View File

@@ -1,13 +1,20 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AramaFunnelKarti } from "@/components/arama-funnel-karti"; import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
import { RehberKapak } from "@/features/rehber/components/rehber-kapak"; import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama"; import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import type { RehberOzet } from "@/lib/rehber"; import type { RehberAramaKaydi } from "@/lib/rehber";
import { import {
profilDuzenlemeyiAc, profilDuzenlemeyiAc,
sonucaGitIste, sonucaGitIste,
@@ -60,6 +67,18 @@ const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
const REHBER_SONUC_SINIRI = 4; const REHBER_SONUC_SINIRI = 4;
const sayi = (deger: number) => deger.toLocaleString("tr-TR"); const sayi = (deger: number) => deger.toLocaleString("tr-TR");
// Platform değişmez — abonelik gerektirmeyen dış değer (useSyncExternalStore)
const bosAbonelik = () => () => {};
const macMiSunucu = () => true;
function macMiOku(): boolean {
const platform =
(navigator as { userAgentData?: { platform?: string } }).userAgentData
?.platform ??
navigator.platform ??
"";
return /mac|iphone|ipad/i.test(platform);
}
const KESFET_TONLARI = { const KESFET_TONLARI = {
slate: { slate: {
pencere: "border-slate-200/80 bg-white", pencere: "border-slate-200/80 bg-white",
@@ -81,7 +100,7 @@ const KESFET_TONLARI = {
}, },
} as const; } as const;
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) { export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
const router = useRouter(); const router = useRouter();
const profil = useTercihProfili(); const profil = useTercihProfili();
const [acik, setAcik] = useState(false); const [acik, setAcik] = useState(false);
@@ -95,7 +114,10 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const [sorgu, setSorgu] = useState(""); const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR); const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
const [yukleniyor, setYukleniyor] = useState(false); const [yukleniyor, setYukleniyor] = useState(false);
const [macMi, setMacMi] = useState(true); // Kısayol etiketi (⌘/Ctrl) platforma bağlı sabit bir dış değer: effect'te
// setState yerine useSyncExternalStore — SSR'da ⌘ (eski varsayılan),
// hydration sonrası gerçek platform.
const macMi = useSyncExternalStore(bosAbonelik, macMiOku, macMiSunucu);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const kaydirmaRef = useRef<HTMLDivElement>(null); const kaydirmaRef = useRef<HTMLDivElement>(null);
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false }); const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
@@ -139,15 +161,6 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
sonuclar.universiteler.length > 0 || sonuclar.universiteler.length > 0 ||
rehberSonuclari.length > 0; rehberSonuclari.length > 0;
useEffect(() => {
const platform =
(navigator as { userAgentData?: { platform?: string } }).userAgentData
?.platform ??
navigator.platform ??
"";
setMacMi(/mac|iphone|ipad/i.test(platform));
}, []);
useEffect(() => { useEffect(() => {
function kisayolDinle(event: KeyboardEvent) { function kisayolDinle(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
@@ -722,7 +735,7 @@ function RehberSonucKarti({
yazi, yazi,
onSec, onSec,
}: { }: {
yazi: RehberOzet; yazi: RehberAramaKaydi;
onSec: () => void; onSec: () => void;
}) { }) {
return ( return (

View File

@@ -36,13 +36,15 @@ function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
export function UniversiteKatalogu() { export function UniversiteKatalogu() {
const hepsi = getAllUniversiteler(); const hepsi = getAllUniversiteler();
const devlet = hepsi.filter((u) => u.unitur === "DEVLET"); // Tek geçişte üç grup — filter×3 + includes'lı O(n²) tarama yerine
const vakif = hepsi.filter( const devlet: UniOzet[] = [];
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur), const vakif: UniOzet[] = [];
); const digerUniler: UniOzet[] = [];
const digerUniler = hepsi.filter( for (const u of hepsi) {
(u) => !devlet.includes(u) && !vakif.includes(u), if (u.unitur === "DEVLET") devlet.push(u);
); else if (u.unitur === "VAKIF" || u.unitur === "VAKIF MYO") vakif.push(u);
else digerUniler.push(u);
}
return ( return (
<> <>

View File

@@ -13,7 +13,12 @@ import {
Scale, Scale,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/types/yokatlas"; import type {
DilimKey,
ProgramListeSatiri,
PuanTuruKey,
RankResultsListe,
} from "@/types/yokatlas";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk"; import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug"; import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import { import {
@@ -70,7 +75,7 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
riskli: "bg-red-500", riskli: "bg-red-500",
}; };
const BOS_EKSTRA: Record<DilimKey, Program[]> = { const BOS_EKSTRA: Record<DilimKey, ProgramListeSatiri[]> = {
hayal: [], hayal: [],
dengeli: [], dengeli: [],
garanti: [], garanti: [],
@@ -78,7 +83,7 @@ const BOS_EKSTRA: Record<DilimKey, Program[]> = {
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir // Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır. // "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
function varsayilanDilim(sonuclar: RankResults): DilimKey { function varsayilanDilim(sonuclar: RankResultsListe): DilimKey {
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0 return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
? "garanti" ? "garanti"
: "dengeli"; : "dengeli";
@@ -89,7 +94,7 @@ export function ProgramTablosu({
adaySira, adaySira,
tur, tur,
}: { }: {
sonuclar: RankResults; sonuclar: RankResultsListe;
adaySira: number; adaySira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
}) { }) {
@@ -120,7 +125,7 @@ export function ProgramTablosu({
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`, `/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
); );
if (!res.ok) return; if (!res.ok) return;
const veri = (await res.json()) as { programlar: Program[] }; const veri = (await res.json()) as { programlar: ProgramListeSatiri[] };
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir // Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
setEkstra((mevcut) => { setEkstra((mevcut) => {
const eldekiler = new Set( const eldekiler = new Set(
@@ -232,7 +237,7 @@ function ProgramSatiri({
adaySira, adaySira,
tur, tur,
}: { }: {
program: Program; program: ProgramListeSatiri;
adaySira: number; adaySira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
}) { }) {

View File

@@ -1,12 +1,45 @@
import "server-only"; import "server-only";
import { searchByRank } from "@/lib/db"; import { searchByRank } from "@/lib/db";
import type { PuanTuruKey, RankResults } from "@/types/yokatlas"; import type {
Program,
ProgramListeSatiri,
PuanTuruKey,
RankResultsListe,
} from "@/types/yokatlas";
// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin.
// Alanlar tek tek seçilir: Program'a alan eklenirse ProgramListeSatiri
// üzerinden derleyici burada da karar vermeye zorlar.
export function programOzetle(p: Program): ProgramListeSatiri {
return {
id: p.id,
isim: p.isim,
universite: p.universite,
unitur: p.unitur,
il: p.il,
tur: p.tur,
sure: p.sure,
sira2025: p.sira2025,
sira2024: p.sira2024,
sira2023: p.sira2023,
sira2022: p.sira2022,
sira2021: p.sira2021,
kontenjan2025: p.kontenjan2025,
yerlesen2025: p.yerlesen2025,
};
}
// /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar, // /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır. // hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
export function siraylaProgramAra( export function siraylaProgramAra(
sira: number, sira: number,
tur: PuanTuruKey, tur: PuanTuruKey,
): RankResults { ): RankResultsListe {
return searchByRank(sira, tur, { limitPerBucket: 20 }); const ham = searchByRank(sira, tur, { limitPerBucket: 20 });
return {
hayal: ham.hayal.map(programOzetle),
dengeli: ham.dengeli.map(programOzetle),
garanti: ham.garanti.map(programOzetle),
toplam: ham.toplam,
};
} }

View File

@@ -14,9 +14,11 @@ export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
const geriDonus = siparis const geriDonus = siparis
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}` ? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
: "/paket"; : "/paket";
const session = await verifySession(geriDonus); // Oturum ve sipariş sorgusu bağımsız; sahiplik kontrolü aşağıda yapılıyor
const [session, order] = await Promise.all([
const order = siparis ? await siparisGetir(siparis) : null; verifySession(geriDonus),
siparis ? siparisGetir(siparis) : Promise.resolve(null),
]);
// Yalnızca kendi siparişi görüntülenebilir // Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id; const sahibiMi = order?.userId === session.user.id;

View File

@@ -14,11 +14,12 @@ export async function baslatOdeme(
_prev: OdemeBaslatDurum, _prev: OdemeBaslatDurum,
formData: FormData, formData: FormData,
): Promise<OdemeBaslatDurum> { ): Promise<OdemeBaslatDurum> {
const session = await verifySession("/paket"); // Ucuz senkron doğrulama pahalı oturum turundan önce
const product = formData.get("urun"); const product = formData.get("urun");
if (product !== "paket" && product !== "topup") { if (product !== "paket" && product !== "topup") {
return { error: "Geçersiz ürün." }; return { error: "Geçersiz ürün." };
} }
const session = await verifySession("/paket");
const urun = URUNLER[product]; const urun = URUNLER[product];
const orderId = crypto.randomUUID(); const orderId = crypto.randomUUID();

View File

@@ -7,5 +7,7 @@ export {
getAllRehberler, getAllRehberler,
getAllRehberSlugs, getAllRehberSlugs,
getRehber, getRehber,
getRehberAramaKayitlari,
type RehberOzet, type RehberOzet,
type RehberAramaKaydi,
} from "@/lib/rehber"; } from "@/lib/rehber";

View File

@@ -3,7 +3,7 @@
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç. // /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak. // Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Trophy } from "lucide-react"; import { ArrowLeft, Trophy } from "lucide-react";
@@ -18,17 +18,14 @@ import {
export function SiraGerekli() { export function SiraGerekli() {
const router = useRouter(); const router = useRouter();
const { profil, profilYuklendi } = useProfilKapisi(); const { profil, profilYuklendi } = useProfilKapisi();
const [yonleniyor, setYonleniyor] = useState(false);
// "Yönleniyor" durumu ayrı state gerektirmez: profil varken aşağıdaki koşul
// zaten bekleme ekranını gösteriyor; effect yalnızca yönlendirmeyi yapar.
useEffect(() => { useEffect(() => {
if (!profilYuklendi) return; if (profilYuklendi && profil) router.replace(sonucHref(profil));
if (profil) {
setYonleniyor(true);
router.replace(sonucHref(profil));
}
}, [profil, profilYuklendi, router]); }, [profil, profilYuklendi, router]);
if (!profilYuklendi || yonleniyor || profil) { if (!profilYuklendi || profil) {
return ( return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center"> <main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor</p> <p className="text-sm text-slate-500">Sıralaman kontrol ediliyor</p>

View File

@@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { after } from "next/server";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb"; import { appDb, schema } from "./appdb";
import { grantCredits, URUNLER } from "./credits"; import { grantCredits, URUNLER } from "./credits";
@@ -62,7 +63,10 @@ export async function odemeyiSonuclandir(
// Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir // Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir
// kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner). // kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner).
await sunucuOlayi("odeme_tamamlandi", { // after(): yanıt gönderildikten sonra koşar — callback redirect'ini ve
// /odeme/sonuc render'ını Rybbit'in 3 sn'lik timeout'u bloklayamaz.
after(() =>
sunucuOlayi("odeme_tamamlandi", {
kullaniciId: order.userId, kullaniciId: order.userId,
pathname: "/odeme/sonuc", pathname: "/odeme/sonuc",
ozellikler: { ozellikler: {
@@ -70,7 +74,8 @@ export async function odemeyiSonuclandir(
kredi: order.credits, kredi: order.credits,
tutar: order.amountKurus / 100, tutar: order.amountKurus / 100,
}, },
}); }),
);
return "paid"; return "paid";
} }

View File

@@ -25,6 +25,13 @@ export type RehberOzet = {
tarih: string; tarih: string;
}; };
/**
* Header'daki ⌘K aramasına giden asgari alan seti. `tarih` client'ta hiç
* okunmadığı için RSC payload'ına taşınmaz (root layout üzerinden her
* sayfaya serileşiyordu).
*/
export type RehberAramaKaydi = Pick<RehberOzet, "slug" | "baslik" | "aciklama">;
export type RehberSema = { export type RehberSema = {
/** Mermaid tanımı (aria/altyazi yönergeleri ayıklanmış hâli) */ /** Mermaid tanımı (aria/altyazi yönergeleri ayıklanmış hâli) */
tanim: string; tanim: string;
@@ -40,6 +47,16 @@ export type RehberYazi = RehberOzet & { parcalar: RehberParca[] };
const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber"); const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber");
// SiteHeader root layout'ta olduğu için getAllRehberler her sayfa
// render/prerender'ında koşuyor; cache'siz hâli sayfa başına 24 dosya okuma +
// 24 marked.parse demekti. Yalnızca prod'da cache'lenir — dev'de içerik
// düzenlenirken taze fs okuması korunur (katalog.ts/db.ts globalThis deseni).
const globalRehber = globalThis as unknown as {
rehberYazilar?: Map<string, RehberYazi | null>;
rehberOzetler?: RehberOzet[];
};
const CACHE_AKTIF = process.env.NODE_ENV === "production";
function parseFrontmatter(raw: string): { function parseFrontmatter(raw: string): {
meta: Record<string, string>; meta: Record<string, string>;
body: string; body: string;
@@ -105,22 +122,63 @@ function parcala(slug: string, body: string): RehberParca[] {
return parcalar; return parcalar;
} }
function oku(slug: string): RehberYazi | null { function metaDogrula(
const dosya = path.join(REHBER_DIZIN, `${slug}.md`); slug: string,
if (!fs.existsSync(dosya)) return null; meta: Record<string, string>,
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8")); ): asserts meta is { baslik: string; aciklama: string; tarih: string } {
if (!meta.baslik || !meta.aciklama || !meta.tarih) { if (!meta.baslik || !meta.aciklama || !meta.tarih) {
throw new Error( throw new Error(
`rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`, `rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`,
); );
} }
return { }
function oku(slug: string): RehberYazi | null {
if (CACHE_AKTIF) {
const onbellek = globalRehber.rehberYazilar?.get(slug);
if (onbellek !== undefined) return onbellek;
}
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
let yazi: RehberYazi | null = null;
if (fs.existsSync(dosya)) {
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
metaDogrula(slug, meta);
yazi = {
slug, slug,
baslik: meta.baslik, baslik: meta.baslik,
aciklama: meta.aciklama, aciklama: meta.aciklama,
tarih: meta.tarih, tarih: meta.tarih,
parcalar: parcala(slug, body), parcalar: parcala(slug, body),
}; };
}
if (CACHE_AKTIF) {
(globalRehber.rehberYazilar ??= new Map()).set(slug, yazi);
}
return yazi;
}
/**
* Liste görünümleri için fast-path: yalnızca frontmatter parse edilir,
* `parcala()`/`marked.parse` hiç çağrılmaz (özetler `parcalar`ı kullanmıyor).
* Tam parse zaten cache'teyse dosyaya inmeden onu kırpar.
*/
function ozetOku(slug: string): RehberOzet | null {
const tam = CACHE_AKTIF ? globalRehber.rehberYazilar?.get(slug) : undefined;
if (tam !== undefined) {
if (tam === null) return null;
const { slug: s, baslik, aciklama, tarih } = tam;
return { slug: s, baslik, aciklama, tarih };
}
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
if (!fs.existsSync(dosya)) return null;
const { meta } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
metaDogrula(slug, meta);
return {
slug,
baslik: meta.baslik,
aciklama: meta.aciklama,
tarih: meta.tarih,
};
} }
export function getAllRehberSlugs(): string[] { export function getAllRehberSlugs(): string[] {
@@ -139,14 +197,22 @@ export function getRehber(slug: string): RehberYazi | null {
} }
export function getAllRehberler(): RehberOzet[] { export function getAllRehberler(): RehberOzet[] {
return getAllRehberSlugs() if (CACHE_AKTIF && globalRehber.rehberOzetler) {
.map((slug) => oku(slug)) return globalRehber.rehberOzetler;
.filter((y): y is RehberYazi => y !== null) }
.map(({ slug, baslik, aciklama, tarih }) => ({ const ozetler = getAllRehberSlugs()
.map((slug) => ozetOku(slug))
.filter((y): y is RehberOzet => y !== null)
.sort((a, b) => b.tarih.localeCompare(a.tarih));
if (CACHE_AKTIF) globalRehber.rehberOzetler = ozetler;
return ozetler;
}
/** Header aramasının RSC payload'ına giden kırpılmış liste. */
export function getRehberAramaKayitlari(): RehberAramaKaydi[] {
return getAllRehberler().map(({ slug, baslik, aciklama }) => ({
slug, slug,
baslik, baslik,
aciklama, aciklama,
tarih, }));
}))
.sort((a, b) => b.tarih.localeCompare(a.tarih));
} }

View File

@@ -41,6 +41,20 @@ export type RankResults = {
toplam: Record<DilimKey, number>; toplam: Record<DilimKey, number>;
}; };
/**
* /sonuc ham tablosunun satırı: tablo `fakulte` ve `puan2025`'i hiç
* kullanmadığı için bu alanlar client'a serileştirilmez (satır başına en
* uzun string alanlardan biri fakulte). Katalog sayfaları tam Program kullanır.
*/
export type ProgramListeSatiri = Omit<Program, "fakulte" | "puan2025">;
export type RankResultsListe = {
hayal: ProgramListeSatiri[];
dengeli: ProgramListeSatiri[];
garanti: ProgramListeSatiri[];
toplam: Record<DilimKey, number>;
};
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO, // Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer. // YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
export type UniturGrubu = "devlet" | "vakif"; export type UniturGrubu = "devlet" | "vakif";