Some checks failed
Deploy / deploy (push) Failing after 1h28m6s
- Added new scripts for database operations and temporary production in package.json. - Integrated KaydetBannerLazy component into the layout for improved user notifications. - Enhanced API error handling in the soru route to mark credit exhaustion. - Updated the IletisimPage for better button styling and user experience. - Refactored ListemPage to streamline user flow and improve session handling. - Removed unused ListePaneli component to clean up the codebase.
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
"use client";
|
||
|
||
// Sonuç sayfasının Yapay Zeka'sız ham listesi: yalnızca başarı sıralamasıyla
|
||
// yokatlas'tan gelen programlar, dilim sekmeleri halinde ve yakından uzağa
|
||
// sayfalanır ("daha fazla göster" /api/programlar'dan akıtır). Her satırın "+"
|
||
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
|
||
|
||
import { useState } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
ChevronDown,
|
||
Rocket,
|
||
Scale,
|
||
ShieldCheck,
|
||
} from "lucide-react";
|
||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
|
||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||
import {
|
||
ProgramSiraGecmisi,
|
||
ProgramTabanTrendi,
|
||
programEtkinSira,
|
||
} from "@/components/program-liste-verileri";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||
import { UniLogo } from "@/components/uni-logo";
|
||
|
||
const DILIMLER: {
|
||
key: DilimKey;
|
||
etiket: string;
|
||
aciklama: string;
|
||
icon: typeof Rocket;
|
||
renk: string;
|
||
}[] = [
|
||
{
|
||
key: "hayal",
|
||
etiket: "Hayal",
|
||
aciklama: "Tabanı sıralamanın üstünde — şansını denediğin satırlar.",
|
||
icon: Rocket,
|
||
renk: "text-red-500",
|
||
},
|
||
{
|
||
key: "dengeli",
|
||
etiket: "Dengeli",
|
||
aciklama: "Tabanı sıralamana denk — listenin bel kemiği.",
|
||
icon: Scale,
|
||
renk: "text-amber-500",
|
||
},
|
||
{
|
||
key: "garanti",
|
||
etiket: "Garanti",
|
||
aciklama: "Tabanı belirgin şekilde altında — güvenli bölge.",
|
||
icon: ShieldCheck,
|
||
renk: "text-emerald-600",
|
||
},
|
||
];
|
||
|
||
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
|
||
guvenli: "bg-emerald-500",
|
||
"az-riskli": "bg-amber-500",
|
||
riskli: "bg-red-500",
|
||
};
|
||
|
||
const BOS_EKSTRA: Record<DilimKey, Program[]> = {
|
||
hayal: [],
|
||
dengeli: [],
|
||
garanti: [],
|
||
};
|
||
|
||
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
|
||
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
|
||
function varsayilanDilim(sonuclar: RankResults): DilimKey {
|
||
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
|
||
? "garanti"
|
||
: "dengeli";
|
||
}
|
||
|
||
export function ProgramTablosu({
|
||
sonuclar,
|
||
adaySira,
|
||
tur,
|
||
}: {
|
||
sonuclar: RankResults;
|
||
adaySira: number;
|
||
tur: PuanTuruKey;
|
||
}) {
|
||
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
|
||
varsayilanDilim(sonuclar),
|
||
);
|
||
|
||
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür değişince
|
||
// (puan türü seçici aynı bileşeni yeniden render eder, remount etmez)
|
||
// render sırasında sıfırlanır ki eski türün satırları yenisine karışmasın.
|
||
const [ekstra, setEkstra] = useState(BOS_EKSTRA);
|
||
const [yuklenen, setYuklenen] = useState<DilimKey | null>(null);
|
||
const veriAnahtari = `${adaySira}-${tur}`;
|
||
const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari);
|
||
if (veriAnahtari !== oncekiAnahtar) {
|
||
setOncekiAnahtar(veriAnahtari);
|
||
setEkstra(BOS_EKSTRA);
|
||
// Yeni sıra/türde dengeli+hayal boş kaldıysa boş sekmede bırakma
|
||
if (varsayilanDilim(sonuclar) === "garanti" && aktifDilim !== "garanti") {
|
||
setAktifDilim("garanti");
|
||
}
|
||
}
|
||
|
||
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
|
||
setYuklenen(dilim);
|
||
try {
|
||
const res = await fetch(
|
||
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
|
||
);
|
||
if (!res.ok) return;
|
||
const veri = (await res.json()) as { programlar: Program[] };
|
||
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
|
||
setEkstra((mevcut) => {
|
||
const eldekiler = new Set(
|
||
[...sonuclar[dilim], ...mevcut[dilim]].map((p) => p.id),
|
||
);
|
||
return {
|
||
...mevcut,
|
||
[dilim]: [
|
||
...mevcut[dilim],
|
||
...veri.programlar.filter((p) => !eldekiler.has(p.id)),
|
||
],
|
||
};
|
||
});
|
||
} catch {
|
||
} finally {
|
||
setYuklenen(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
|
||
<Tabs
|
||
value={aktifDilim}
|
||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||
>
|
||
{/* Dilim sekmeleri tablonun hemen üstünde; puan türü burada değil,
|
||
navbar'daki sıralama rozetinden değiştirilir. */}
|
||
<TabsList>
|
||
{DILIMLER.map((d) => (
|
||
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
|
||
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
|
||
{d.etiket}
|
||
<span className="text-xs tabular-nums text-slate-400">
|
||
{sonuclar.toplam[d.key].toLocaleString("tr-TR")}
|
||
</span>
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
|
||
{DILIMLER.map((d) => {
|
||
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]];
|
||
const toplam = sonuclar.toplam[d.key];
|
||
return (
|
||
<TabsContent key={d.key} value={d.key}>
|
||
<p className="mt-2 text-xs text-slate-500">{d.aciklama}</p>
|
||
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||
{satirlar.length === 0 ? (
|
||
<p className="px-5 py-8 text-center text-sm text-slate-500">
|
||
Bu dilimde filtrene uyan program bulunamadı.
|
||
</p>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-8" />
|
||
<TableHead>Program</TableHead>
|
||
<TableHead className="hidden text-right md:table-cell">
|
||
Son 5 yıl
|
||
</TableHead>
|
||
<TableHead className="hidden text-right sm:table-cell">
|
||
Kontenjan
|
||
</TableHead>
|
||
<TableHead className="text-right">Taban sıra</TableHead>
|
||
<TableHead className="w-24 text-right">
|
||
<span className="sr-only">Netler ve listeye ekle</span>
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{satirlar.map((p) => (
|
||
<ProgramSatiri
|
||
key={p.id}
|
||
program={p}
|
||
adaySira={adaySira}
|
||
tur={tur}
|
||
/>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</div>
|
||
{satirlar.length < toplam ? (
|
||
<div className="mt-3 flex flex-col items-center gap-1">
|
||
<button
|
||
type="button"
|
||
disabled={yuklenen === d.key}
|
||
onClick={() => dahaFazlaYukle(d.key, satirlar.length)}
|
||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors duration-150 hover:border-slate-300 hover:bg-slate-50 disabled:cursor-default disabled:opacity-60"
|
||
>
|
||
<ChevronDown className="size-4" aria-hidden />
|
||
{yuklenen === d.key ? "Yükleniyor…" : "Daha fazla göster"}
|
||
</button>
|
||
<span className="text-xs tabular-nums text-slate-400">
|
||
{satirlar.length.toLocaleString("tr-TR")} /{" "}
|
||
{toplam.toLocaleString("tr-TR")} program
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</TabsContent>
|
||
);
|
||
})}
|
||
</Tabs>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ProgramSatiri({
|
||
program: p,
|
||
adaySira,
|
||
tur,
|
||
}: {
|
||
program: Program;
|
||
adaySira: number;
|
||
tur: PuanTuruKey;
|
||
}) {
|
||
const taban = programEtkinSira(p);
|
||
const risk = riskHesapla(taban, adaySira);
|
||
const devlet = p.unitur === "DEVLET";
|
||
|
||
return (
|
||
<TableRow>
|
||
<TableCell className="pl-4 pr-0">
|
||
{risk ? (
|
||
<span
|
||
className={`block size-2.5 rounded-full ${RISK_NOKTA[risk]}`}
|
||
aria-hidden
|
||
/>
|
||
) : null}
|
||
</TableCell>
|
||
<TableCell className="max-w-0 w-full">
|
||
<span className="flex min-w-0 items-center gap-2.5">
|
||
<UniLogo ad={p.universite} boy="sm" />
|
||
<span className="min-w-0 flex-1">
|
||
<Link
|
||
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
|
||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
title={`${p.isim} bölüm sayfası`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{p.isim}
|
||
</Link>
|
||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||
<span
|
||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||
devlet
|
||
? "bg-slate-100 text-slate-600"
|
||
: "bg-violet-50 text-violet-600"
|
||
}`}
|
||
>
|
||
{devlet ? "Devlet" : "Vakıf"}
|
||
</span>
|
||
<span className="truncate">
|
||
<Link
|
||
href={`/universite/${uniSayfaSlug(p.universite)}`}
|
||
className="hover:text-primary hover:underline"
|
||
title={`${p.universite} taban puanları sayfası`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{p.universite}
|
||
</Link>
|
||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||
</span>
|
||
{p.sure ? (
|
||
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="hidden text-right text-xs md:table-cell">
|
||
<ProgramSiraGecmisi program={p} />
|
||
</TableCell>
|
||
<TableCell
|
||
className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"
|
||
title={
|
||
p.kontenjan2025 != null && p.yerlesen2025 != null
|
||
? `${p.kontenjan2025} kontenjan, ${p.yerlesen2025} yerleşen`
|
||
: undefined
|
||
}
|
||
>
|
||
{p.kontenjan2025 != null ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{p.yerlesen2025 != null && p.yerlesen2025 < p.kontenjan2025 ? (
|
||
<span className="rounded bg-amber-50 px-1 py-px text-[10px] font-medium leading-4 text-amber-600">
|
||
boş kaldı
|
||
</span>
|
||
) : null}
|
||
{p.kontenjan2025.toLocaleString("tr-TR")} kişi
|
||
</span>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||
<span className="inline-flex items-center gap-1">
|
||
<ProgramTabanTrendi program={p} />
|
||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="pr-4 text-right">
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ProgramNetleriButonu
|
||
programId={p.id}
|
||
isim={p.isim}
|
||
universite={p.universite}
|
||
className="size-8 min-h-8 min-w-8"
|
||
/>
|
||
<ProgramEkleButonu
|
||
program={p}
|
||
kaynak="sonuc"
|
||
adaySira={adaySira}
|
||
adayTur={tur}
|
||
className="size-8 min-h-8 min-w-8"
|
||
/>
|
||
</span>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|