Kritik (K1-K6) sonrası kalan tüm maddeler: - Değer önce: girişsiz kullanıcı sihirbaz bitince /giris yerine /sonuc'taki ücretsiz tabloya gider (?hazir=1); AI CTA'sı "5 deneme kredinle listeni kur — giriş yap" paneli + sabit alt karttan verilir, girişte otomatik üretim - Ö1-Ö10: paywall kopyası, aranabilir il seçimi, "Seçtiklerim" adlandırması, gizlilik/koşullar/iletişim sayfaları, danışman özeti + uyarılar, revizyon kutusu, dilim özeti + tek şehir uyarısı, eksik veri rozetleri, magic link yeniden gönder, mobil sohbet kısayolu - G1-G5: binlik ayraçlı input + inline hata, adım göstergesi, route-level loading/error/not-found, dürüst konumlandırma kartı, dürüst buton etiketleri - Seçtiklerim butonu liste boşken gizli; ilk eklemede belirir - Dev süperadmin paneli (yalnızca development): hızlı giriş, kredi/paket/rapor senaryoları, sahte ödeme dönüşü Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
"use client";
|
||
|
||
// Sonuç sayfasının AI'sız ham listesi: yalnızca başarı sıralamasıyla
|
||
// yokatlas'tan gelen programlar, dilim sekmeleri halinde. 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 { useLinkStatus } from "next/link";
|
||
import {
|
||
Check,
|
||
Plus,
|
||
Rocket,
|
||
Scale,
|
||
ShieldCheck,
|
||
TrendingDown,
|
||
TrendingUp,
|
||
} from "lucide-react";
|
||
import type { Program, PuanTuruKey, RankResults } from "@/lib/db";
|
||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
MANUEL_LISTE_MAX,
|
||
ekle,
|
||
useManuelListe,
|
||
type ManuelTercih,
|
||
} from "@/components/manuel-liste/store";
|
||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||
|
||
type DilimKey = keyof RankResults;
|
||
|
||
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",
|
||
};
|
||
|
||
// Seçicideki kısa etiketler — sayfa başlığındaki uzun hallerinden bağımsız
|
||
const TUR_ETIKETLERI: Record<PuanTuruKey, string> = {
|
||
say: "Sayısal",
|
||
ea: "Eşit Ağırlık",
|
||
soz: "Sözel",
|
||
dil: "Dil",
|
||
tyt: "TYT",
|
||
};
|
||
|
||
// useLinkStatus Link'in altındaki bir bileşende çağrılmalı; sunucu
|
||
// gidiş-dönüşü sürerken tıklanan pill hafifçe nabız atar (layout shift yok).
|
||
function SeciciEtiket({ children }: { children: React.ReactNode }) {
|
||
const { pending } = useLinkStatus();
|
||
return (
|
||
<span
|
||
className={`transition-opacity duration-150 ${
|
||
pending ? "animate-pulse opacity-60" : ""
|
||
}`}
|
||
>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PuanTuruSecici({
|
||
sira,
|
||
aktif,
|
||
}: {
|
||
sira: number;
|
||
aktif: PuanTuruKey;
|
||
}) {
|
||
return (
|
||
<nav
|
||
aria-label="Puan türü"
|
||
className="flex w-fit max-w-full flex-wrap gap-1 rounded-full border border-slate-200 bg-slate-100 p-1"
|
||
>
|
||
{(Object.keys(TUR_ETIKETLERI) as PuanTuruKey[]).map((key) => (
|
||
<Link
|
||
key={key}
|
||
href={`/sonuc?sira=${sira}&tur=${key}`}
|
||
scroll={false}
|
||
aria-current={key === aktif ? "page" : undefined}
|
||
className={`cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-[background-color,color,transform] duration-150 active:scale-[0.97] ${
|
||
key === aktif
|
||
? "bg-white text-slate-900 shadow-sm"
|
||
: "text-slate-500 hover:text-slate-900"
|
||
}`}
|
||
>
|
||
<SeciciEtiket>{TUR_ETIKETLERI[key]}</SeciciEtiket>
|
||
</Link>
|
||
))}
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
function efektifSira(p: Program): number | null {
|
||
return p.sira2025 ?? p.sira2024;
|
||
}
|
||
|
||
function tercihYap(p: Program, adaySira: number): ManuelTercih {
|
||
const taban = efektifSira(p);
|
||
return {
|
||
id: p.id,
|
||
isim: p.isim,
|
||
universite: p.universite,
|
||
il: p.il,
|
||
efektifSira: taban,
|
||
risk: riskHesapla(taban, adaySira),
|
||
};
|
||
}
|
||
|
||
export function ProgramTablosu({
|
||
sonuclar,
|
||
adaySira,
|
||
tur,
|
||
}: {
|
||
sonuclar: RankResults;
|
||
adaySira: number;
|
||
tur: PuanTuruKey;
|
||
}) {
|
||
const liste = useManuelListe();
|
||
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
|
||
const listedekiler = new Set(liste.map((t) => t.id));
|
||
const doldu = liste.length >= MANUEL_LISTE_MAX;
|
||
|
||
function eklemeyiDene(p: Program) {
|
||
ekle(tercihYap(p, adaySira));
|
||
}
|
||
|
||
return (
|
||
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
|
||
<div>
|
||
<h2 className="font-heading text-2xl font-bold">
|
||
Sıralamanla açılan programlar
|
||
</h2>
|
||
<p className="mt-1 max-w-2xl text-sm text-slate-500">
|
||
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
|
||
sıralamalarına göre ulaşabildiğin bölümler.{" "}
|
||
<span className="font-medium text-slate-700">
|
||
+ ile beğendiklerini Seçtiklerim'e at.
|
||
</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
|
||
<div className="mt-5">
|
||
<ManuelHarita />
|
||
</div>
|
||
|
||
<Tabs
|
||
value={aktifDilim}
|
||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||
className="mt-5"
|
||
>
|
||
{/* Kontrol çubuğu: tabloyu süzen her şey tablonun hemen üstünde —
|
||
solda dilim sekmeleri (birincil), sağda puan türü (ikincil filtre).
|
||
Dar ekranda alt alta sarar. */}
|
||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||
<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[d.key].length}
|
||
</span>
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
||
Puan türü
|
||
</span>
|
||
{/* scroll={false}: tür değişince sayfa konumu korunur */}
|
||
<PuanTuruSecici sira={adaySira} aktif={tur} />
|
||
</div>
|
||
</div>
|
||
|
||
{DILIMLER.map((d) => (
|
||
<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">
|
||
{sonuclar[d.key].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-16 text-right">
|
||
<span className="sr-only">Listeye ekle</span>
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{sonuclar[d.key].map((p) => (
|
||
<ProgramSatiri
|
||
key={p.id}
|
||
program={p}
|
||
adaySira={adaySira}
|
||
listede={listedekiler.has(p.id)}
|
||
doldu={doldu}
|
||
onEkle={eklemeyiDene}
|
||
/>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
))}
|
||
</Tabs>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// Tabanın 2021–2025 seyri: küçük sıra = daha rekabetçi olduğundan y ekseni
|
||
// ters çevrilir; çizgi yukarı gidiyorsa bölüm yıllar içinde zorlaşıyor demektir.
|
||
function SiraGecmisi({ p }: { p: Program }) {
|
||
const yillar = [
|
||
[2021, p.sira2021],
|
||
[2022, p.sira2022],
|
||
[2023, p.sira2023],
|
||
[2024, p.sira2024],
|
||
[2025, p.sira2025],
|
||
].filter((y): y is [number, number] => y[1] != null);
|
||
if (yillar.length < 2) return <span className="text-slate-400">—</span>;
|
||
|
||
const siralar = yillar.map(([, s]) => s);
|
||
const min = Math.min(...siralar);
|
||
const max = Math.max(...siralar);
|
||
const W = 56;
|
||
const H = 18;
|
||
const PAD = 2;
|
||
const noktalar = yillar.map(([yil, s], i) => {
|
||
const x = PAD + (i / (yillar.length - 1)) * (W - PAD * 2);
|
||
// min sıra (en rekabetçi) üstte dursun
|
||
const y =
|
||
max === min
|
||
? H / 2
|
||
: PAD + ((s - min) / (max - min)) * (H - PAD * 2);
|
||
return { x, y, yil, s };
|
||
});
|
||
const son = noktalar[noktalar.length - 1];
|
||
const ozet = yillar
|
||
.map(([yil, s]) => `${yil}: ~${s.toLocaleString("tr-TR")}.`)
|
||
.join("\n");
|
||
|
||
return (
|
||
<svg
|
||
viewBox={`0 0 ${W} ${H}`}
|
||
className="inline-block h-[18px] w-14 text-slate-300"
|
||
role="img"
|
||
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
|
||
>
|
||
<title>{ozet}</title>
|
||
<polyline
|
||
points={noktalar.map((n) => `${n.x},${n.y}`).join(" ")}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// Taban sıra iki yıl üst üste %10'dan fazla oynadıysa yönünü gösterir;
|
||
// küçük sıra = daha rekabetçi taban demektir.
|
||
function TabanTrendi({ p }: { p: Program }) {
|
||
if (p.sira2025 == null || p.sira2024 == null) return null;
|
||
const oran = (p.sira2024 - p.sira2025) / p.sira2024;
|
||
if (Math.abs(oran) < 0.1) return null;
|
||
const yukseliyor = oran > 0;
|
||
const gecis = `${p.sira2024.toLocaleString("tr-TR")} → ${p.sira2025.toLocaleString("tr-TR")}`;
|
||
return yukseliyor ? (
|
||
<TrendingUp
|
||
className="size-3 shrink-0 text-amber-500"
|
||
aria-label={`Taban yükseliyor: ${gecis}`}
|
||
role="img"
|
||
>
|
||
<title>{`Taban yükseliyor: ${gecis}`}</title>
|
||
</TrendingUp>
|
||
) : (
|
||
<TrendingDown
|
||
className="size-3 shrink-0 text-emerald-600"
|
||
aria-label={`Taban geriliyor: ${gecis}`}
|
||
role="img"
|
||
>
|
||
<title>{`Taban geriliyor: ${gecis}`}</title>
|
||
</TrendingDown>
|
||
);
|
||
}
|
||
|
||
function ProgramSatiri({
|
||
program: p,
|
||
adaySira,
|
||
listede,
|
||
doldu,
|
||
onEkle,
|
||
}: {
|
||
program: Program;
|
||
adaySira: number;
|
||
listede: boolean;
|
||
doldu: boolean;
|
||
onEkle: (p: Program) => void;
|
||
}) {
|
||
const taban = efektifSira(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="block truncate text-sm font-semibold">{p.isim}</span>
|
||
<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">
|
||
{p.universite}
|
||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||
</span>
|
||
{p.sure ? (
|
||
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
|
||
) : null}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="hidden text-right text-xs md:table-cell">
|
||
<SiraGecmisi p={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">
|
||
<TabanTrendi p={p} />
|
||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="pr-4 text-right">
|
||
<button
|
||
type="button"
|
||
disabled={listede || doldu}
|
||
onClick={() => onEkle(p)}
|
||
title={
|
||
listede
|
||
? "Zaten seçtiklerinde"
|
||
: doldu
|
||
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
|
||
: "Seçtiklerime ekle"
|
||
}
|
||
className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default ${
|
||
listede
|
||
? "border-emerald-200 bg-emerald-50 text-emerald-600"
|
||
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
|
||
}`}
|
||
>
|
||
{listede ? (
|
||
<Check className="size-4" aria-hidden />
|
||
) : (
|
||
<Plus className="size-4" aria-hidden />
|
||
)}
|
||
<span className="sr-only">
|
||
{listede
|
||
? `${p.isim} seçtiklerinde`
|
||
: `${p.isim} seçtiklerine ekle`}
|
||
</span>
|
||
</button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|