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:
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ export {
|
|||||||
getAllRehberler,
|
getAllRehberler,
|
||||||
getAllRehberSlugs,
|
getAllRehberSlugs,
|
||||||
getRehber,
|
getRehber,
|
||||||
|
getRehberAramaKayitlari,
|
||||||
type RehberOzet,
|
type RehberOzet,
|
||||||
|
type RehberAramaKaydi,
|
||||||
} from "@/lib/rehber";
|
} from "@/lib/rehber";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user