Sonuç sayfasına bölge haritası ile il seçimi eklendi; searchByRank artık il filtresini destekliyor. Paket adı tercihai olarak güncellendi. Co-authored-by: Cursor <cursoragent@cursor.com>
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import Link from "next/link";
|
||
import type { Metadata } from "next";
|
||
import {
|
||
ArrowLeft,
|
||
MapPin,
|
||
Minus,
|
||
Rocket,
|
||
Scale,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
TrendingDown,
|
||
TrendingUp,
|
||
X,
|
||
} from "lucide-react";
|
||
import {
|
||
PUAN_TURLERI,
|
||
searchByRank,
|
||
type Program,
|
||
type PuanTuruKey,
|
||
} from "@/lib/db";
|
||
import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
|
||
export const metadata: Metadata = {
|
||
title: "Sıralamana Uygun Programlar — TercihAI",
|
||
};
|
||
|
||
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||
say: "Sayısal",
|
||
ea: "Eşit Ağırlık",
|
||
soz: "Sözel",
|
||
dil: "Dil",
|
||
tyt: "TYT (Önlisans)",
|
||
};
|
||
|
||
function TrendIcon({ p }: { p: Program }) {
|
||
if (p.sira2025 == null || p.sira2024 == null) {
|
||
return <Minus className="size-4 text-slate-400" aria-label="Veri yok" />;
|
||
}
|
||
const degisim = (p.sira2024 - p.sira2025) / p.sira2024;
|
||
// taban sıralaması küçülüyorsa bölüm zorlaşıyor demektir
|
||
if (degisim > 0.05) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 text-orange-600">
|
||
<TrendingUp className="size-4" aria-hidden />
|
||
<span className="text-xs">zorlaşıyor</span>
|
||
</span>
|
||
);
|
||
}
|
||
if (degisim < -0.05) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||
<TrendingDown className="size-4" aria-hidden />
|
||
<span className="text-xs">rahatlıyor</span>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="inline-flex items-center gap-1 text-slate-500">
|
||
<Minus className="size-4" aria-hidden />
|
||
<span className="text-xs">stabil</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function fmt(n: number | null): string {
|
||
return n == null ? "—" : n.toLocaleString("tr-TR");
|
||
}
|
||
|
||
function ProgramTable({ programs }: { programs: Program[] }) {
|
||
if (programs.length === 0) {
|
||
return (
|
||
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||
Bu dilimde uygun program bulunamadı.
|
||
</p>
|
||
);
|
||
}
|
||
return (
|
||
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-slate-50">
|
||
<TableHead>Program</TableHead>
|
||
<TableHead>Üniversite</TableHead>
|
||
<TableHead className="text-right">2025 taban sırası</TableHead>
|
||
<TableHead className="text-right">2024</TableHead>
|
||
<TableHead>Trend</TableHead>
|
||
<TableHead className="text-right">Kontenjan</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{programs.map((p) => (
|
||
<TableRow key={p.id}>
|
||
<TableCell className="font-medium">
|
||
{p.isim}
|
||
{p.fakulte ? (
|
||
<span className="block text-xs text-slate-500">
|
||
{p.fakulte}
|
||
</span>
|
||
) : null}
|
||
</TableCell>
|
||
<TableCell>
|
||
{p.universite}
|
||
<span className="block text-xs text-slate-500">
|
||
{p.il}
|
||
{p.unitur ? ` · ${p.unitur.toLocaleLowerCase("tr-TR")}` : ""}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="text-right font-medium tabular-nums">
|
||
{fmt(p.sira2025)}
|
||
</TableCell>
|
||
<TableCell className="text-right tabular-nums text-slate-500">
|
||
{fmt(p.sira2024)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<TrendIcon p={p} />
|
||
</TableCell>
|
||
<TableCell className="text-right tabular-nums">
|
||
{fmt(p.kontenjan2025)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default async function SonucPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<{ sira?: string; tur?: string; il?: string }>;
|
||
}) {
|
||
const params = await searchParams;
|
||
const sira = Number.parseInt(params.sira ?? "", 10);
|
||
const turKey: PuanTuruKey =
|
||
params.tur && params.tur in PUAN_TURLERI
|
||
? (params.tur as PuanTuruKey)
|
||
: "say";
|
||
const seciliIl = params.il?.trim() || null;
|
||
|
||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||
return (
|
||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||
<h1 className="font-heading text-2xl font-bold">
|
||
Geçerli bir sıralama gerekli
|
||
</h1>
|
||
<p className="mt-3 text-slate-600">
|
||
Sonuçları görebilmek için ana sayfadan YKS başarı sıralamanı gir.
|
||
</p>
|
||
<Button asChild className="mt-6 cursor-pointer">
|
||
<Link href="/">
|
||
<ArrowLeft className="size-4" aria-hidden />
|
||
Ana sayfaya dön
|
||
</Link>
|
||
</Button>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
const results = searchByRank(sira, turKey, {
|
||
il: seciliIl ?? undefined,
|
||
});
|
||
// Harita her zaman filtresiz dağılımı gösterir ki il değiştirilebilsin
|
||
const haritaSonuclari = seciliIl ? searchByRank(sira, turKey) : results;
|
||
|
||
// Harita için il bazında özet (81 il haritada; KKTC/yurtdışı ayrıca listelenir)
|
||
const iller: Record<string, IlOzeti> = {};
|
||
const dilimler = [
|
||
["hayal", haritaSonuclari.hayal],
|
||
["dengeli", haritaSonuclari.dengeli],
|
||
["garanti", haritaSonuclari.garanti],
|
||
] as const;
|
||
for (const [dilim, programs] of dilimler) {
|
||
for (const p of programs) {
|
||
if (!p.il) continue;
|
||
iller[p.il] ??= { hayal: 0, dengeli: 0, garanti: 0 };
|
||
iller[p.il][dilim]++;
|
||
}
|
||
}
|
||
|
||
const sections = [
|
||
{
|
||
key: "hayal",
|
||
icon: Rocket,
|
||
title: "Hayal",
|
||
tone: "text-orange-600",
|
||
description:
|
||
"Geçen yıl tabanı senin sıralamanın üzerinde kapanan programlar. Şansın düşük ama listenin başında birkaç tane bulunmalı.",
|
||
programs: results.hayal,
|
||
},
|
||
{
|
||
key: "dengeli",
|
||
icon: Scale,
|
||
title: "Dengeli",
|
||
tone: "text-primary",
|
||
description:
|
||
"Tabanı sıralamanın hemen civarında. Listenin bel kemiği bu dilimden kurulur.",
|
||
programs: results.dengeli,
|
||
},
|
||
{
|
||
key: "garanti",
|
||
icon: ShieldCheck,
|
||
title: "Garanti",
|
||
tone: "text-emerald-600",
|
||
description:
|
||
"Tabanı sıralamanın belirgin altında. Boşta kalmamak için listenin sonunda mutlaka yer almalı.",
|
||
programs: results.garanti,
|
||
},
|
||
] as const;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||
<header className="sticky top-4 z-50 mx-4">
|
||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between rounded-2xl border border-slate-200 bg-white/80 px-5 shadow-sm backdrop-blur">
|
||
<Link
|
||
href="/"
|
||
className="flex items-center gap-2 font-heading text-lg font-bold"
|
||
>
|
||
<Sparkles className="size-5 text-primary" aria-hidden />
|
||
TercihAI
|
||
</Link>
|
||
<Button asChild size="sm" variant="outline" className="cursor-pointer">
|
||
<Link href="/">
|
||
<ArrowLeft className="size-4" aria-hidden />
|
||
Yeni arama
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto max-w-6xl px-4 py-12">
|
||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||
Ücretsiz ön izleme
|
||
</Badge>
|
||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için görünüm
|
||
</h1>
|
||
<p className="mt-2 max-w-2xl text-slate-600">
|
||
2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler.
|
||
Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç
|
||
noktasıdır.
|
||
</p>
|
||
|
||
{/* Puan türü seçimi */}
|
||
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Puan türü">
|
||
{(Object.keys(PUAN_TURLERI) as PuanTuruKey[]).map((key) => (
|
||
<Button
|
||
key={key}
|
||
asChild
|
||
size="sm"
|
||
variant={key === turKey ? "default" : "outline"}
|
||
className="cursor-pointer"
|
||
>
|
||
<Link
|
||
href={`/sonuc?sira=${sira}&tur=${key}${
|
||
seciliIl ? `&il=${encodeURIComponent(seciliIl)}` : ""
|
||
}`}
|
||
>
|
||
{TUR_LABELS[key]}
|
||
</Link>
|
||
</Button>
|
||
))}
|
||
</nav>
|
||
|
||
{seciliIl ? (
|
||
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||
<MapPin className="size-4 text-primary" aria-hidden />
|
||
<p className="text-sm font-medium">
|
||
Yalnızca <span className="font-bold">{seciliIl}</span>{" "}
|
||
programları gösteriliyor
|
||
</p>
|
||
<Button
|
||
asChild
|
||
size="sm"
|
||
variant="outline"
|
||
className="cursor-pointer"
|
||
>
|
||
<Link href={`/sonuc?sira=${sira}&tur=${turKey}`}>
|
||
<X className="size-3.5" aria-hidden />
|
||
Filtreyi kaldır
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Bölge haritası */}
|
||
<section aria-labelledby="baslik-harita" className="mt-10">
|
||
<h2 id="baslik-harita" className="font-heading text-2xl font-bold">
|
||
Nerede okuyacaksın?
|
||
</h2>
|
||
<p className="mt-1 text-sm text-slate-600">
|
||
Aşağıdaki önerilerin illere dağılımı. Koyu renk, o ilde daha fazla
|
||
öneri olduğunu gösterir. Bir ile tıklayarak listeyi o ile
|
||
daraltabilir, tekrar tıklayarak filtreyi kaldırabilirsin.
|
||
</p>
|
||
<div className="mt-4 rounded-2xl border border-slate-200 bg-white p-4 sm:p-6">
|
||
<BolgeHaritasi
|
||
iller={iller}
|
||
sira={sira}
|
||
turKey={turKey}
|
||
seciliIl={seciliIl}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="mt-10 space-y-12">
|
||
{sections.map((s) => (
|
||
<section key={s.key} aria-labelledby={`baslik-${s.key}`}>
|
||
<div className="mb-4 flex items-start gap-3">
|
||
<s.icon className={`mt-1 size-6 ${s.tone}`} aria-hidden />
|
||
<div>
|
||
<h2
|
||
id={`baslik-${s.key}`}
|
||
className="font-heading text-2xl font-bold"
|
||
>
|
||
{s.title}
|
||
<span className="ml-2 text-base font-normal text-slate-500">
|
||
{s.programs.length} program
|
||
</span>
|
||
</h2>
|
||
<p className="text-sm text-slate-600">{s.description}</p>
|
||
</div>
|
||
</div>
|
||
<ProgramTable programs={s.programs} />
|
||
</section>
|
||
))}
|
||
</div>
|
||
|
||
{/* Ücretli katman CTA */}
|
||
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
|
||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||
Bu listeyi 24 tercihlik plana çevirelim mi?
|
||
</h2>
|
||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||
Yapay zekâ danışman; şehir, bölüm ve kariyer hedeflerine göre bu
|
||
dilimlerden gerekçeli, dengeli bir tercih listesi kurar. Çok
|
||
yakında.
|
||
</p>
|
||
<Button
|
||
size="lg"
|
||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||
disabled
|
||
>
|
||
AI danışman yakında
|
||
</Button>
|
||
</section>
|
||
|
||
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
||
Veriler resmî YÖK Atlas kaynağından derlenmiştir. TercihAI bir karar
|
||
destek aracıdır; yerleşme garantisi vermez, tercih sorumluluğu adaya
|
||
aittir.
|
||
</p>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|