Files
kolaytercih/src/features/katalog/components/program-ayrinti.tsx
bilalgursen 3c995402c6 feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00

546 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Program satırındaki "ayrıntı" düğmesi: programın tüm açık verisini alttan
// açılan sayfada gösterir (DESIGN.md §4.10 çekmece, §4.19 program ayrıntı
// çekmecesi). Veri /api/program-ayrinti'den gelir: künye (dil, öğretim türü,
// burs/ücret, akreditasyon, baraj, kadro, kampüs, cinsiyet koşulu), yıllara
// göre sıra/puan/kontenjan/yerleşen, kontenjan türleri, son yerleşenin
// netleri, yerleşme koşulları, TÜİK mezun istihdamı, üniversite özeti.
// Kadın-erkek dağılımı program düzeyinde bilinçli olarak yok: YÖK Atlas 2026
// SPA'sına geçerken cinsiyet panelini yayından kaldırdı (bkz. scripts/detay.ts).
// Çekmece içerik ağırlıklı olduğu için liste çekmecesi gibi kaydırılır (§4.10
// tek ekran kuralının ikinci istisnası).
import { SATIR_BASLIK, SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { useState } from "react";
import {
BadgeCheck,
Clock,
Flag,
GraduationCap,
Info,
Languages,
MapPin,
Monitor,
Users,
Wallet,
X,
} from "lucide-react";
import type { ProgramAyrintisi } from "@/app/api/program-ayrinti/route";
import type { KontenjanTuruAnahtari, ProgramNetSatiri } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
// Yalnız saf modüller: bu bileşen istemcide çalışır, db'ye dokunan
// program-ozellik.ts import EDİLMEZ (kadro toplamı API'den gelir)
import { CINSIYET_KOSULU_METNI, KONTENJAN_TURU_ADI } from "@/lib/ozel-durum";
import { trBaslikDuzeni } from "@/lib/slug";
import { UNITUR_ETIKET } from "@/lib/unitur";
import { cn } from "@/lib/utils";
import { SON_YIL } from "@/lib/veri-yillari";
import {
AltSayfa,
AltSayfaKapat,
AltSayfaGovde,
AltSayfaIcerik,
AltSayfaAciklama,
AltSayfaBaslik,
AltSayfaTetik,
} from "@/components/ui/alt-sayfa";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Ders sırası ÖSYM oturum sırasıyla; max = testteki soru sayısı.
const DERSLER: {
alan: keyof Omit<ProgramNetSatiri, "yil" | "puanTuru" | "tabanPuan" | "obp" | "katsayi">;
etiket: string;
max: number;
}[] = [
{ alan: "tytTrkNet", etiket: "TYT Türkçe", max: 40 },
{ alan: "tytSosNet", etiket: "TYT Sosyal Bilimler", max: 20 },
{ alan: "tytMatNet", etiket: "TYT Matematik", max: 40 },
{ alan: "tytFenNet", etiket: "TYT Fen Bilimleri", max: 20 },
{ alan: "aytMatNet", etiket: "AYT Matematik", max: 40 },
{ alan: "aytFizNet", etiket: "AYT Fizik", max: 14 },
{ alan: "aytKimNet", etiket: "AYT Kimya", max: 13 },
{ alan: "aytBioNet", etiket: "AYT Biyoloji", max: 13 },
{ alan: "aytTdeNet", etiket: "AYT Türk Dili ve Edebiyatı", max: 24 },
{ alan: "aytTrh1Net", etiket: "AYT Tarih-1", max: 10 },
{ alan: "aytCog1Net", etiket: "AYT Coğrafya-1", max: 6 },
{ alan: "aytTrh2Net", etiket: "AYT Tarih-2", max: 11 },
{ alan: "aytCog2Net", etiket: "AYT Coğrafya-2", max: 11 },
{ alan: "aytDinNet", etiket: "AYT Din Kültürü", max: 6 },
{ alan: "aytFelNet", etiket: "AYT Felsefe Grubu", max: 12 },
{ alan: "ydtYdilNet", etiket: "YDT Yabancı Dil", max: 80 },
];
/** Meslek lisesi alan içi ek puanla yerleşmeyi gösteren OBP katsayısı. */
const EK_PUAN_KATSAYISI = 0.18;
const KONTENJAN_TURU_SIRASI: KontenjanTuruAnahtari[] = [
"genel",
"okulBirincisi",
"sehitGaziYakini",
"depremzede",
"kadin34",
];
const sayi = (n: number) => n.toLocaleString("tr-TR");
const ondalik = (n: number) =>
n.toLocaleString("tr-TR", { maximumFractionDigits: 2 });
/** Satır kimlik hapı (DESIGN.md §4.12 ölçüsü, link değil). */
const HAP =
"inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-sm font-semibold text-slate-800";
const HAP_IKON = "size-4 shrink-0 text-primary";
const BOLUM_BASLIK = "mt-7 font-heading text-base font-bold text-slate-900";
const NOT = "mt-2 text-sm leading-relaxed text-slate-500";
type Durum = "bekliyor" | "yukleniyor" | "hazir" | "hata";
export function ProgramAyrintiButonu({
programId,
isim,
universite,
className = "",
varyant = "ikon",
}: {
programId: string;
isim: string;
universite?: string;
className?: string;
/** ikon: satır düğmesi (yuvarlak); metin: açık satır içindeki metin düğmesi ("Haritada gör" ile aynı kalıp) */
varyant?: "ikon" | "metin";
}) {
const [durum, setDurum] = useState<Durum>("bekliyor");
const [veri, setVeri] = useState<ProgramAyrintisi | null>(null);
async function yukle() {
setDurum("yukleniyor");
try {
const res = await fetch(`/api/program-ayrinti?id=${programId}`);
if (!res.ok) throw new Error(String(res.status));
setVeri((await res.json()) as ProgramAyrintisi);
setDurum("hazir");
} catch {
setDurum("hata");
}
}
function acildi(acik: boolean) {
if (!acik) return;
olay("program_ayrinti_goruntulendi");
if (durum === "bekliyor" || durum === "hata") yukle();
}
return (
<AltSayfa onOpenChange={acildi}>
<AltSayfaTetik asChild>
{varyant === "metin" ? (
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={`inline-flex min-h-11 cursor-pointer items-center gap-1.5 text-sm font-semibold text-primary transition-colors duration-150 hover:text-orange-500 ${className}`}
>
<Info className="size-4" aria-hidden />
Program ayrıntısı
<span className="sr-only"> — {isim}</span>
</button>
) : (
<button
type="button"
title="Program ayrıntısı"
onClick={(e) => e.stopPropagation()}
className={`inline-flex ${SATIR_DUGME_BOYU} cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
>
<Info className="size-4" aria-hidden />
<span className="sr-only">{isim} — program ayrıntısı</span>
</button>
)}
</AltSayfaTetik>
{/* Alt sayfa kabı tek kaynak (alt-sayfa.tsx), çarpı §4.3. İçerik uzun:
sayfa ekranın %60'ında açılır (başlık + künye), yukarı çekince ya da
kaydırınca tam boya çıkar (§4.19). */}
<AltSayfaIcerik
duraklar={[0.6, 0.92]}
ilkDurak={0}
onClick={(e) => e.stopPropagation()}
>
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</AltSayfaKapat>
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{isim}
</AltSayfaBaslik>
<AltSayfaAciklama className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
{veri
? [
trBaslikDuzeni(veri.program.universite),
veri.program.il ? trBaslikDuzeni(veri.program.il) : null,
veri.program.fakulte,
]
.filter(Boolean)
.join(" · ")
: (universite ?? "Programın tüm açık verisi")}
</AltSayfaAciklama>
<div className="mt-5">
<AyrintiIcerik durum={durum} veri={veri} />
</div>
</AltSayfaGovde>
</AltSayfaIcerik>
</AltSayfa>
);
}
function AyrintiIcerik({ durum, veri }: { durum: Durum; veri: ProgramAyrintisi | null }) {
if (durum === "yukleniyor" || durum === "bekliyor" || (durum === "hazir" && !veri)) {
return (
<div className="space-y-2" aria-hidden>
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 animate-pulse rounded-lg bg-slate-100" />
))}
</div>
);
}
if (durum === "hata" || !veri) {
return (
<p className="py-4 text-center text-base text-slate-500">
Ayrıntı yüklenemedi. Paneli kapatıp yeniden açmayı dene.
</p>
);
}
return (
<>
<Kunye veri={veri} />
<YillaraGore veri={veri} />
<KontenjanTurleriBolumu veri={veri} />
<h3 className={BOLUM_BASLIK}>Son yerleşenin netleri</h3>
<p className={NOT}>
Bölüme en düşük puanla yerleşen adayın sınav netleri (YÖK Atlas).
</p>
<div className="mt-3">
<NetlerIcerik netler={veri.netler} />
</div>
<Kosullar veri={veri} />
<Istihdam veri={veri} />
<UniversiteOzeti veri={veri} />
<p className="mt-7 border-t border-slate-100 pt-3 text-xs leading-5 text-slate-500">
Kaynaklar: YÖK Atlas (yerleştirme, kontenjan, netler, koşullar; {SON_YIL}{" "}
yerleştirmesi), TÜİK (mezun istihdamı), URAP ve YÖK İstatistik (üniversite).
</p>
</>
);
}
function Kunye({ veri }: { veri: ProgramAyrintisi }) {
const o = veri.ozellik;
const haplar: { ikon: typeof Languages; metin: string }[] = [];
const tur = veri.program.unitur ? UNITUR_ETIKET[veri.program.unitur]?.cip : null;
if (veri.program.sure) {
haplar.push({
ikon: Clock,
metin: `${veri.program.sure} yıl${tur ? ` · ${tur}` : ""}`,
});
}
if (o?.ogrenimDili) haplar.push({ ikon: Languages, metin: o.ogrenimDili });
if (o?.ogrenimTuru && o.ogrenimTuru !== "Örgün Öğretim") {
haplar.push({ ikon: Monitor, metin: o.ogrenimTuru });
}
if (o?.bursOrani) {
const ucret =
o.ucret != null && o.bursOrani !== "Burslu" ? ` · yıllık ${sayi(o.ucret)} TL` : "";
haplar.push({ ikon: Wallet, metin: `${o.bursOrani}${ucret}` });
}
if (o?.akreditasyon) haplar.push({ ikon: BadgeCheck, metin: `${o.akreditasyon} akreditasyonlu` });
if (o?.minBasariSirasi != null) {
haplar.push({ ikon: Flag, metin: `Baraj ${sayi(o.minBasariSirasi)}. sıra` });
}
const uye = veri.ogretimUyesiToplam;
if (uye != null) haplar.push({ ikon: GraduationCap, metin: `${sayi(uye)} öğretim üyesi` });
if (o?.cinsiyetKosulu) {
const m = CINSIYET_KOSULU_METNI[o.cinsiyetKosulu];
haplar.push({ ikon: Users, metin: m.charAt(0).toLocaleUpperCase("tr-TR") + m.slice(1) });
}
if (o?.ilce) haplar.push({ ikon: MapPin, metin: `${trBaslikDuzeni(o.ilce)} kampüsü` });
if (haplar.length === 0) return null;
return (
<ul className="flex flex-wrap justify-center gap-2">
{haplar.map((h) => (
<li key={h.metin} className={HAP}>
<h.ikon className={HAP_IKON} aria-hidden />
{h.metin}
</li>
))}
</ul>
);
}
function YillaraGore({ veri }: { veri: ProgramAyrintisi }) {
const satirlar = veri.gecmis.filter(
(g) => g.sira != null || g.puan != null || g.kontenjan != null || g.yerlesen != null,
);
if (satirlar.length === 0) return null;
const sonYil = satirlar[0].yil;
const hucre = (yil: number) =>
cn(
"py-2.5 text-right text-sm tabular-nums",
yil === sonYil ? "font-semibold text-slate-800" : "text-slate-500",
);
return (
<>
<h3 className={BOLUM_BASLIK}>Yıllara göre</h3>
<p className={NOT}>
Taban sırası (ve geniş ekranda puanı) o yıl yerleşen son adayınki;
kontenjan ve yerleşen o yılın yerleştirmesi. Sırası boş yıl: program o
yıl dolmadı ya da yoktu.
</p>
<Table className="mt-3">
<TableHeader>
<TableRow>
<TableHead className={cn(SATIR_BASLIK, "pl-0")}>Yıl</TableHead>
<TableHead className={cn(SATIR_BASLIK, "text-right")}>Taban sıra</TableHead>
{/* Telefonda beş sütun 375 px'e sığmıyor (yatay kaydırma yok, §5.2.3): puan sm'den itibaren */}
<TableHead className={cn(SATIR_BASLIK, "hidden text-right sm:table-cell")}>Puan</TableHead>
<TableHead className={cn(SATIR_BASLIK, "text-right")}>Kontenjan</TableHead>
<TableHead className={cn(SATIR_BASLIK, "text-right")}>Yerleşen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{satirlar.map((g) => (
<TableRow key={g.yil}>
<TableCell
className={cn(
"py-2.5 pl-0 text-sm tabular-nums",
g.yil === sonYil ? "font-semibold text-slate-900" : "text-slate-700",
)}
>
{g.yil}
</TableCell>
<TableCell className={hucre(g.yil)}>{g.sira != null ? sayi(g.sira) : "—"}</TableCell>
<TableCell className={cn(hucre(g.yil), "hidden sm:table-cell")}>{g.puan != null ? ondalik(g.puan) : "—"}</TableCell>
<TableCell className={hucre(g.yil)}>{g.kontenjan != null ? sayi(g.kontenjan) : "—"}</TableCell>
<TableCell className={hucre(g.yil)}>{g.yerlesen != null ? sayi(g.yerlesen) : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
}
function KontenjanTurleriBolumu({ veri }: { veri: ProgramAyrintisi }) {
const k = veri.kontenjanTurleri;
if (!k) return null;
const satirlar = KONTENJAN_TURU_SIRASI.filter((t) => (k[t].kontenjan ?? 0) > 0);
if (satirlar.length === 0) return null;
const ozelVar = satirlar.some((t) => t !== "genel");
return (
<>
<h3 className={BOLUM_BASLIK}>Kontenjan türleri ({k.yil})</h3>
<p className={NOT}>
{ozelVar
? "ÖSYM okul birincileri, şehit ve gazi yakınları, depremzedeler ve 34 yaş üstü kadınlar için ayrı kontenjan ayırır; bu kontenjanlar yalnız o durumdaki adaylara açıktır ve her birinin taban sırası ayrı yayımlanır — o sıra burada yok."
: "Bu programda yalnız genel kontenjan var."}
</p>
<dl className="mt-3 grid gap-x-6 gap-y-2 sm:grid-cols-2">
{satirlar.map((t) => (
<div key={t} className="flex items-baseline justify-between gap-3 border-b border-slate-100 py-2">
<dt className="text-sm text-slate-700">{KONTENJAN_TURU_ADI[t]}</dt>
<dd className="text-right text-sm tabular-nums text-slate-900">
<span className="font-semibold">{sayi(k[t].kontenjan ?? 0)}</span>
<span className="text-slate-500">
{" "}
kontenjan · {k[t].yerlesen != null ? sayi(k[t].yerlesen) : "—"} yerleşen
</span>
</dd>
</div>
))}
</dl>
</>
);
}
function NetlerIcerik({ netler }: { netler: ProgramNetSatiri[] }) {
if (netler.length === 0) {
return (
<p className="py-2 text-base text-slate-500">
Bu program için YÖK Atlas&apos;ta net verisi yayınlanmamış.
</p>
);
}
const dersler = DERSLER.filter((d) => netler.some((y) => y[d.alan] != null));
// En yeni yıl vurgulu (§1.1 "sık bakılan önde"); eski yıllar bağlam.
const sonYil = Math.max(...netler.map((y) => y.yil));
const ekPuanli = netler.some((y) => y.katsayi != null && y.katsayi >= EK_PUAN_KATSAYISI);
const hucre = (yil: number, vurgu = false) =>
cn(
"py-2.5 text-right text-sm tabular-nums",
yil === sonYil
? vurgu
? "font-semibold text-slate-900"
: "font-semibold text-slate-800"
: "text-slate-500",
);
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className={cn(SATIR_BASLIK, "pl-0")}>Ders</TableHead>
{netler.map((y) => (
<TableHead
key={y.yil}
className={cn(
SATIR_BASLIK,
"text-right tabular-nums",
y.yil === sonYil && "text-slate-900",
)}
>
{y.yil}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{dersler.map((d) => (
<TableRow key={d.alan}>
{/* Ders adı sarılır: 375 px'te üç yıl sütunu + "AYT Türk Dili ve
Edebiyatı / 24" tek satırda 343 px'lik kaba sığmıyor, tablo
yatay kayıyordu (6 Eki 2026 QA). Sayı hücreleri nowrap kalır. */}
<TableCell className="py-2.5 pl-0 pr-3 text-sm leading-snug whitespace-normal text-slate-700">
{d.etiket}
<span className="text-slate-400"> / {d.max}</span>
</TableCell>
{netler.map((y) => {
const deger = y[d.alan];
return (
<TableCell key={y.yil} className={hucre(y.yil)}>
{deger != null ? ondalik(deger) : "—"}
</TableCell>
);
})}
</TableRow>
))}
<TableRow>
<TableCell className="py-2.5 pl-0 text-sm text-slate-500">OBP</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2.5 text-right text-sm tabular-nums text-slate-500"
>
{y.obp != null ? ondalik(y.obp) : "—"}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="py-2.5 pl-0 text-sm text-slate-500">OBP katsayısı</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2.5 text-right text-sm tabular-nums text-slate-500"
>
{y.katsayi != null ? ondalik(y.katsayi) : "—"}
</TableCell>
))}
</TableRow>
<TableRow className="border-t border-slate-200">
<TableCell className="py-3 pl-0 text-sm font-semibold text-slate-900">
Yerleşme puanı
</TableCell>
{netler.map((y) => (
<TableCell key={y.yil} className={hucre(y.yil, true)}>
{y.tabanPuan != null ? ondalik(y.tabanPuan) : "—"}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
{ekPuanli ? (
<p className={NOT}>
OBP katsayısı {ondalik(EK_PUAN_KATSAYISI)} olan yılda son yerleşen,
meslek lisesi alan içi ek puanıyla yerleşmiş; standart katsayı 0,12.
</p>
) : null}
</>
);
}
function Kosullar({ veri }: { veri: ProgramAyrintisi }) {
if (veri.kosullar.length === 0) return null;
return (
<details className="group mt-7">
<summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 font-heading text-base font-bold text-slate-900 transition-colors hover:text-orange-500 [&::-webkit-details-marker]:hidden">
Yerleşme koşulları ({veri.kosullar.length})
<span className="text-sm font-semibold text-slate-500 group-open:hidden">Göster</span>
<span className="hidden text-sm font-semibold text-slate-500 group-open:inline">Gizle</span>
</summary>
<ol className="mt-2 space-y-3 text-sm leading-relaxed text-slate-600">
{veri.kosullar.map((k) => (
<li key={k.kod} className="flex gap-3">
<span className="shrink-0 tabular-nums text-slate-400">{k.kod}</span>
<span>{k.metin}</span>
</li>
))}
</ol>
<p className={NOT}>
Koşul metinleri ÖSYM tercih kılavuzundan; tercih göndermeden önce güncel
kılavuzdan doğrula.
</p>
</details>
);
}
function Istihdam({ veri }: { veri: ProgramAyrintisi }) {
const v = veri.istihdam;
if (!v) return null;
const parcalar = [
v.istihdam != null ? `mezunların %${ondalik(v.istihdam)}'i kayıtlı çalışıyor` : null,
v.isBulmaAy != null ? `ilk iş ortalama ${ondalik(v.isBulmaAy)} ayda` : null,
v.kazanc ? `kazanç grubu ${v.kazanc.toLocaleLowerCase("tr-TR")}` : null,
].filter(Boolean);
if (parcalar.length === 0) return null;
const ort = v.ortalama.istihdam != null ? ` Türkiye ortalaması %${ondalik(v.ortalama.istihdam)}.` : "";
return (
<>
<h3 className={BOLUM_BASLIK}>Mezun istihdamı</h3>
<p className={NOT}>
{parcalar.join(", ")} (TÜİK {v.yil}, ülke geneli, {v.duzey === "lisans" ? "lisans" : "ön lisans"}{" "}
mezunları; üniversiteye göre değişir, kırılım yok).{ort}
</p>
</>
);
}
function UniversiteOzeti({ veri }: { veri: ProgramAyrintisi }) {
const u = veri.universite;
if (!u || (!u.urap && !u.istatistik)) return null;
const parcalar = [
u.urap ? `URAP araştırma sıralaması ${u.urap.sira}/${u.urap.toplam} (${u.urap.donem})` : null,
u.istatistik
? `öğretim üyesi başına ${sayi(u.istatistik.hocaBasinaOgrenci)} öğrenci (sayfası olan üniversitelerin medyanı ${sayi(u.istatistik.medyan)}; YÖK İstatistik ${u.istatistik.donem})`
: null,
].filter(Boolean);
return (
<>
<h3 className={BOLUM_BASLIK}>Üniversite</h3>
<p className={NOT}>
{parcalar.join(" · ")}.{" "}
<a
href={`/universite/${u.slug}`}
className="font-semibold text-primary transition-colors hover:text-orange-500 hover:underline"
>
Üniversite sayfası
</a>
</p>
</>
);
}