feat: update app.db and BACKLOG.md with new decisions and adjustments
All checks were successful
Deploy / deploy (push) Successful in 7m45s

- Updated the app.db file to reflect recent changes in the application state.
- Modified BACKLOG.md to include new entries regarding the removal of certain wizard steps, adjustments to priority chips, and the introduction of special quota conditions for candidates.
- Added details on the decision to link priority chips to data sources and clarified the implications for user experience and data handling.
This commit is contained in:
bilalgursen
2026-10-06 17:17:49 +03:00
parent c0696e2cbc
commit a3cb058a3b
74 changed files with 10334 additions and 445 deletions

View File

@@ -2,13 +2,13 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
Armchair,
Briefcase,
Building2,
Calculator,
ChartLine,
ChevronRight,
GraduationCap,
Landmark,
LayoutList,
Target,
} from "lucide-react";
import {
@@ -34,7 +34,13 @@ import { BolumProgramListesi } from "./bolum-program-listesi";
import { VeriKaynagi } from "./veri-kaynagi";
import { NavBaslik } from "./nav-baslik";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { OzetKart } from "@/components/ozet-kart";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import {
KartBaglantisi,
kisaUniAdi,
ortaTabanAciklamasi,
} from "./taban-karti";
import { trBaslikDuzeni } from "@/lib/slug";
import {
BOLUM_ISTIHDAM_KAYNAK_URL,
BOLUM_ISTIHDAM_YIL,
@@ -56,24 +62,6 @@ import {
const sayi = (n: number) => n.toLocaleString("tr-TR");
/**
* Özet kartında iri sayı telefonda ~94 px'e sığmalı (DESIGN.md §4.8):
* 36 px Cal Sans'ta "1.618.988" 123 px ölçüldü, ikonun altına giriyor. 6 ve
* 7 haneli sıralar birimle kısalır: 245.300 → "245" + "bin",
* 1.618.988 → "1,62" + "milyon".
*/
function kartSayisi(n: number): { deger: string; birim?: string } {
// 999.500+ "1.000 bin" olmasın diye milyona geçer
if (n >= 999_500) {
return {
deger: (n / 1e6).toLocaleString("tr-TR", { maximumFractionDigits: 2 }),
birim: "milyon",
};
}
if (n >= 100_000) return { deger: sayi(Math.round(n / 1000)), birim: "bin" };
return { deger: sayi(n) };
}
const ondalik = (n: number) =>
n.toLocaleString("tr-TR", { maximumFractionDigits: 1 });
@@ -420,19 +408,22 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
...(bolum.lisansProgramlari.length > 0 ? ["Lisans"] : []),
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
];
// Özet kartları: devlet ve vakıf ayrı sayılır; KKTC ve yurt dışı
// programları toplamda var ama iki karta da girmez (vakıf sayılmaz).
const devletUni = new Set<string>();
const vakifUni = new Set<string>();
let vakifProgram = 0;
for (const p of tumProgramlar) {
if (p.unitur === "DEVLET") devletUni.add(p.universite);
else if (p.unitur === "VAKIF" || p.unitur === "VAKIF MYO") {
vakifProgram += 1;
vakifUni.add(p.universite);
}
}
const { stats } = bolum;
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
// çekmiyor): öğrencinin sorusuna cevap veren dört sayı — en seçici
// program, devlette en geniş kapı, orta tabanın yıllar içindeki seyri ve
// boş kalan koltuk. Hesap lib/taban-ozeti.ts; program/devlet/vakıf sayıları
// özet paragrafta kalır. Üniversite sayfasıyla aynı düzen (DESIGN.md §4.8).
const { taban } = bolum;
const uniBaglantisi = (universite: string) => (
<KartBaglantisi
href={(() => {
const s = uniSlugFromAd(universite);
return s ? `/universite/${s}` : null;
})()}
>
{kisaUniAdi(trBaslikDuzeni(universite))}
</KartBaglantisi>
);
// TÜİK mezun istihdamı: lisansı olan bölümde lisans tablosu, yalnız ön
// lisansı olanda ön lisans tablosu. Tabloda yoksa satır hiç çizilmez.
const istihdamDuzeyi: IstihdamDuzeyi =
@@ -568,38 +559,62 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
{ilkSayfa ? (
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<OzetKart
ikon={LayoutList}
etiket="Program"
deger={sayi(stats.toplam)}
alt={`${sayi(stats.ilSayisi)} ilde`}
ikon={Target}
etiket="En seçici"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
alt={
taban.enSecici ? (
<>
{uniBaglantisi(taban.enSecici.universite)} · {SON_YIL} taban
sırası
</>
) : (
"henüz taban oluşmadı"
)
}
/>
{/* Etiket kısa kalır ("Devlette en geniş" 131 px'te iki satıra
kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
<OzetKart
ikon={Landmark}
etiket="Devlet"
deger={sayi(stats.devlet)}
alt={
devletUni.size > 0
? `${sayi(devletUni.size)} üniversitede`
: "bu bölümde yok"
}
/>
<OzetKart
ikon={Building2}
etiket="Vakıf"
deger={sayi(vakifProgram)}
alt={
vakifUni.size > 0
? `${sayi(vakifUni.size)} üniversitede`
: "bu bölümde yok"
}
/>
<OzetKart
ikon={Target}
etiket="En iyi sıralama"
{...(stats.minSira != null
? kartSayisi(stats.minSira)
etiket="En geniş kapı"
{...(taban.enGenis
? kartSayisi(taban.enGenis.sira)
: { deger: "—" })}
alt={`${SON_YIL} taban sırası`}
alt={
taban.enGenis ? (
<>
{taban.kume === "devlet" ? "Devlet · " : ""}
{uniBaglantisi(taban.enGenis.universite)} · {SON_YIL} taban
sırası
</>
) : (
"henüz taban oluşmadı"
)
}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban].reverse().find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
})()}
alt={ortaTabanAciklamasi(taban)}
/>
<OzetKart
ikon={Armchair}
etiket="Boş koltuk"
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
alt={
taban.doluluk
? taban.doluluk.bosKoltuk > 0
? `${sayi(taban.doluluk.programSayisi)} programın ${sayi(taban.doluluk.dolmayanProgram)} tanesi dolmadı · ${SON_YIL}`
: `Her program doldu · ${SON_YIL}`
: "kontenjan verisi yok"
}
/>
</dl>
) : null}

View File

@@ -28,7 +28,7 @@ import {
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import { ProgramAyrintiButonu } from "./program-ayrinti";
import { UniLogo } from "@/components/uni-logo";
import {
Table,
@@ -208,7 +208,7 @@ export function BolumProgramListesi({
/>
</TableHead>
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
<span className="sr-only">Ayrıntı ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -290,7 +290,7 @@ export function BolumProgramListesi({
</TableCell>
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
<ProgramAyrintiButonu
programId={p.id}
isim={p.isim.trim()}
universite={uniAd}

View File

@@ -0,0 +1,541 @@
"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 {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
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 (
<Drawer onOpenChange={acildi} handleOnly>
<DrawerTrigger 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>
)}
</DrawerTrigger>
{/* Çekmece kabı ve çarpı cta-sira-form.tsx'teki çekmeceyle birebir (§4.10, §4.3) */}
<DrawerContent
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<DrawerClose 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>
</DrawerClose>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{isim}
</DrawerTitle>
<DrawerDescription 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")}
</DrawerDescription>
<div className="mt-5">
<AyrintiIcerik durum={durum} veri={veri} />
</div>
</div>
</DrawerContent>
</Drawer>
);
}
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>
</>
);
}

View File

@@ -0,0 +1,68 @@
import Link from "next/link";
import type { ReactNode } from "react";
import {
ortaTabanYonu,
YON_ETIKET,
type TabanKumesi,
type TabanDuzeyi,
type TabanOzeti,
} from "@/lib/taban-ozeti";
import { kisaSayi } from "@/components/ozet-kart";
/**
* Üniversite ve bölüm sayfasının özet kartlarının ortak metin ve link
* yardımcıları (DESIGN.md §4.8). Yıl yıl ayrıntı çekmecesi ("Yıllara göre
* taban") 6 Eki 2026'da Bilal'in isteğiyle şimdilik kaldırıldı; geri gelirse
* git geçmişinde `taban-yillari.tsx` + `siranla-kapilar.tsx` olarak duruyor.
*/
/** "2026'da" — yıl eki ünlü uyumuna göre (2023'te, 2026'da, 2027'de). */
export function yilEki(yil: number): string {
const son = yil % 10;
if (son === 3 || son === 4 || son === 5) return `${yil}'te`;
if (son === 0 || son === 6 || son === 9) return `${yil}'da`;
return `${yil}'de`;
}
/** Orta taban kümesinin adı: "Devlet" / "Lisans" / "Önlisans". */
export function kumeAdi(kume: TabanKumesi, duzey: TabanDuzeyi): string {
if (kume === "devlet") return "Devlet";
return duzey === "lisans" ? "Lisans" : "Önlisans";
}
/** Özet kartının açıklaması: "Devlet · 2021'de 105 bin · gevşedi". */
export function ortaTabanAciklamasi(taban: TabanOzeti): string {
const yon = ortaTabanYonu(taban.ortaTaban);
const kume = kumeAdi(taban.kume, taban.duzey);
if (!yon) {
return taban.ortaTabanAdet > 0
? `${kume} · ${taban.ortaTabanAdet.toLocaleString("tr-TR")} programın ortası`
: "henüz taban oluşmadı";
}
return `${kume} · ${yilEki(yon.ilkYil)} ${kisaSayi(yon.ilk)} · ${YON_ETIKET[yon.yon]}`;
}
/** Özet kartı açıklamasındaki ad linki (üniversite ↔ bölüm çapraz bağ). */
export function KartBaglantisi({
href,
children,
}: {
href: string | null;
children: ReactNode;
}) {
if (!href) return <>{children}</>;
return (
<Link
href={href}
className="font-medium text-primary transition-colors hover:text-orange-500 hover:underline"
>
{children}
</Link>
);
}
/** "Koç Üniversitesi" → "Koç Üni.", "… Meslek Yüksekokulu" → "… MYO" — kart açıklaması kısa kalsın. */
export function kisaUniAdi(ad: string): string {
// \b kullanılmaz: JS'te ASCII sınırıdır, "Ü" önünde sözcük sınırı saymaz.
return ad.replace("Üniversitesi", "Üni.").replace("Meslek Yüksekokulu", "MYO");
}

View File

@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
Armchair,
ArrowRight,
Building2,
ChartLine,
ChevronRight,
Landmark,
LayoutList,
@@ -54,7 +56,9 @@ import { UniLogo } from "@/components/uni-logo";
import { UrapLogo } from "./urap-logo";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { NavBaslik } from "./nav-baslik";
import { OzetKart } from "@/components/ozet-kart";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import { KartBaglantisi, ortaTabanAciklamasi } from "./taban-karti";
import { bolumBazAdi } from "@/lib/slug";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
import type { Program } from "@/types/yokatlas";
@@ -468,6 +472,7 @@ export function UniversiteIcerik({
}
const ilkSayfa = sayfa === 1;
const { taban } = uni;
// Tür iki yerde geçer ve bu bilinçli: H1'in altındaki çip taranmak,
// özet paragrafın ilk virgülündeki öbek okunmak içindir. Izgarada "Tür"
// kartı yok (şablon 4 hücre, bkz. aşağıdaki ızgara yorumu). Haritada
@@ -671,39 +676,75 @@ export function UniversiteIcerik({
<div className="relative isolate mt-8 grid grid-cols-2 gap-4 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
{/* Şablon tam 4 hücre bırakıyor: masaüstünde harita 3. sütunu
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Fakülte
sayısı Program kartının alt satırında; boşalan hücrede
Türkiye sırası. Geri kalan akademik ayrıntı alttaki
"Akademik göstergeler ›" penceresinde. */}
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Dört
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
program/kontenjan adedi ilgi çekmiyor): en seçici bölüm
adıyla, orta tabanın yıllar içindeki seyri, doluluk ve
Türkiye sırası. Program, birim ve kontenjan sayıları özet
paragrafta. Hesap lib/taban-ozeti.ts. */}
<dl className="contents">
<OzetKart
ikon={LayoutList}
etiket="Program"
deger={sayi(uni.programSayisi)}
alt={`${sayi(uni.fakulteSayisi)} birimde`}
/>
<OzetKart
ikon={Users}
etiket="Kontenjan"
deger={sayi(uni.stats.toplamKontenjan)}
alt={`${SON_YIL} toplamı`}
/>
<OzetKart
ikon={Target}
etiket="En iyi sıralama"
deger={
uni.stats.minSira != null ? sayi(uni.stats.minSira) : "—"
etiket="En seçici bölüm"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
alt={
taban.enSecici ? (
<>
<KartBaglantisi
href={(() => {
const s = bolumSlugFromIsim(taban.enSecici.isim);
return s ? `/bolum/${s}` : null;
})()}
>
{bolumBazAdi(taban.enSecici.isim)}
</KartBaglantisi>{" "}
· {SON_YIL} taban sırası
</>
) : (
"henüz taban oluşmadı"
)
}
alt={`${SON_YIL} taban sırası`}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban]
.reverse()
.find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
})()}
alt={ortaTabanAciklamasi(taban)}
/>
{taban.doluluk ? (
<OzetKart
ikon={Armchair}
etiket="Doluluk"
deger={`%${taban.doluluk.yuzde}`}
alt={
taban.doluluk.bosKoltuk > 0
? `${sayi(taban.doluluk.kontenjan)} kontenjandan ${sayi(taban.doluluk.bosKoltuk)} koltuk boş kaldı · ${SON_YIL}`
: `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
}
/>
) : (
<OzetKart
ikon={LayoutList}
etiket="Program"
deger={sayi(uni.programSayisi)}
alt={`${sayi(uni.fakulteSayisi)} birimde`}
/>
)}
{uni.urap ? (
<TurkiyeSirasiKarti sira={uni.urap.sira} />
) : (
<OzetKart
ikon={Building2}
etiket="Fakülte / birim"
deger={sayi(uni.fakulteSayisi)}
alt="programların dağıldığı"
ikon={Users}
etiket="Kontenjan"
deger={sayi(uni.stats.toplamKontenjan)}
alt={`${SON_YIL} toplamı`}
/>
)}
</dl>

View File

@@ -22,7 +22,7 @@ import {
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import { ProgramAyrintiButonu } from "./program-ayrinti";
import {
Table,
TableBody,
@@ -79,7 +79,7 @@ export function UniversiteProgramTablosu({
Taban sıra
</TableHead>
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
<span className="sr-only">Ayrıntı ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -141,7 +141,7 @@ export function UniversiteProgramTablosu({
</TableCell>
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
<ProgramAyrintiButonu
programId={p.id}
isim={p.isim.trim()}
/>

View File

@@ -41,7 +41,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { preloadTercihHaritasi } from "@/features/liste/components/tercih-haritasi-lazy";
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
import { ProgramAyrintiButonu } from "@/features/katalog/components/program-ayrinti";
import { UniLogo } from "@/components/uni-logo";
import { RiskEtiketi } from "@/components/risk-etiketi";
import { Button } from "@/components/ui/button";
@@ -275,7 +275,7 @@ export function ProgramTablosu({
Taban sıra
</TableHead>
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
<span className="sr-only">Ayrıntı ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -437,7 +437,7 @@ function ProgramSatiri({
</TableCell>
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
<ProgramAyrintiButonu
programId={p.id}
isim={p.isim}
universite={p.universite}

View File

@@ -15,10 +15,11 @@ config:
flowchart TD
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi<br/>alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, yalnızca ulaşabildiklerin listelenir")
A --> B("2. adım · İl seçimi<br/>programı olan tüm iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
C --> P("Danışmana söylediklerin<br/>öğretim dili, uzaktan/örgün, burs, yıllık ücret sınırı, evden uzaklık — sohbette söyleyip kaydettiklerin varsa havuzu bunlar da süzer")
B --> C("3. adım · Devlet / Vakıf, öncelikler, özel kontenjan durumun<br/>devlet/vakıf sayıları hem alanı hem ili hesaba katar ve havuzu süzer<br/>öncelikler (iş bulma, maaş, akademik kariyer, prestij, kolay yerleşme) ve özel kontenjan durumu (okul birincisi, şehit-gazi yakını, depremzede, 34 yaş üstü kadın) havuzu DARALTMAZ — aşağıdaki uyum puanına girer")
C --> P("Danışmana söylediklerin<br/>öğretim dili, uzaktan/örgün, burs, yıllık ücret sınırı, evden uzaklık — sohbette söyleyip kaydettiklerin varsa havuzu bunlar da süzer; kampüs büyüklüğü ve özel kontenjan durumu süzmez, uyum puanına girer")
P --> H("Aday havuzu<br/>elde kalan gerçek programlar — yalnızca en son tercih kılavuzunda yer alanlar<br/>kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez<br/>yalnızca KKTC uyruklulara ya da meslek lisesi mezunlarına ayrılmış kontenjanlar da havuza girmez<br/>sıralaman bir bölümün başarı sırası barajının gerisindeyse (tıp, hukuk, mühendislik, öğretmenlik gibi) o bölüm de girmez")
H --> S{"Havuzda 24'ten az program mı kaldı?"}
H --> UP("Uyum puanı — kod hesaplar, yapay zekâ değil<br/>önceliklerine göre: mezun istihdamı ve kazanç grubu (TÜİK), üniversitenin araştırma sırası (URAP), programın kadrosu (YÖK Atlas); özel kontenjan durumuna uyan kontenjanı olan program; kampüs büyüklüğü ve evden uzaklık<br/>verisi olmayan program cezalandırılmaz, dilim (hayal/dengeli/güvenli) değişmez<br/>yapay zekâya giden havuz uyum puanı ve sıraya yakınlığa göre seçilir; her satıra 'öne çıkan' veriler yazılır")
UP --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce danışmana söylediklerin, sonra alan, sonra il — listede açıkça belirtilir<br/>seçimine uyan programlar havuzda kalır, boşluk sıralamana en yakın diğer programlarla dolar")
G --> H
H --> U("Havuz yine de dar kalırsa<br/>liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir")
@@ -26,16 +27,16 @@ flowchart TD
U --> Y
Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
R --> L("En fazla 24 tercihlik liste")
L --> L1("üstte hayal — hedef 5, havuz elverdiğince")
L --> L2("ortada dengeli — hedef 13, havuz elverdiğince")
L --> L3("sonda güvenli — hedef 6, havuz elverdiğince")
L --> L1("üstte hayal — hedef 5, 'Kolay yerleşme' seçiliyse 4; havuz elverdiğince")
L --> L2("ortada dengeli — hedef 13, 'Kolay yerleşme' seçiliyse 12; havuz elverdiğince")
L --> L3("sonda güvenli — hedef 6, 'Kolay yerleşme' seçiliyse 8; havuz elverdiğince")
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
class H,Y,P havuz
class H,Y,P,UP havuz
class G,U uyari
class L1 hayal
class L2 dengeli
@@ -45,7 +46,7 @@ export function HuniSemasi() {
return (
<ElCizimiSema
tanim={SEMA}
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla, danışmana söyleyip kaydettiğin tercihler (öğretim dili, uzaktan/örgün, burs, yıllık ücret sınırı, evden uzaklık) varsa onlarla da aday havuzuna daraltılır; havuza yalnızca en son tercih kılavuzunda yer alan programlar girer, kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez, yalnızca KKTC uyruklulara ya da meslek lisesi mezunlarına ayrılmış kontenjanlar da havuza girmez, sıralaman bir bölümün başarı sırası barajının gerisindeyse (tıp, hukuk, mühendislik, öğretmenlik gibi) o bölüm de girmez; alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, il adımında programı olan tüm iller listelenir; havuz 24'ün altına düşerse filtreler önce danışmana söylediklerin, sonra alan, sonra il sırasıyla gevşetilir, gevşeyen seçimine uyan programlar havuzda kalır ve boşluk sıralamana en yakın diğer programlarla dolar, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda güvenli olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar, seçimi ve gerekçeleri kodla denetlenir: eksik kalan satır havuzdan tamamlanır, vaat içeren ya da başka programı anlatan gerekçenin yerine standart açıklama yazılır; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla, danışmana söyleyip kaydettiğin tercihler (öğretim dili, uzaktan/örgün, burs, yıllık ücret sınırı, evden uzaklık) varsa onlarla da aday havuzuna daraltılır; üçüncü adımdaki öncelikler (iş bulma, maaş, akademik kariyer, prestij, kolay yerleşme) ve isteğe bağlı özel kontenjan durumun (okul birincisi, şehit-gazi yakını, depremzede, 34 yaş üstü kadın) havuzu daraltmaz; havuza yalnızca en son tercih kılavuzunda yer alan programlar girer, kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez, yalnızca KKTC uyruklulara ya da meslek lisesi mezunlarına ayrılmış kontenjanlar da havuza girmez, sıralaman bir bölümün başarı sırası barajının gerisindeyse (tıp, hukuk, mühendislik, öğretmenlik gibi) o bölüm de girmez; alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, il adımında programı olan tüm iller listelenir; havuzdaki her program için kod bir uyum puanı hesaplar — önceliklerine göre TÜİK mezun istihdamı ve kazanç grubu, URAP araştırma sırası, programın kadrosu, özel kontenjan durumuna uyan kontenjan, kampüs büyüklüğü ve evden uzaklık; verisi olmayan program cezalandırılmaz, dilim değişmez, yapay zekâya giden havuz bu puana ve sıraya yakınlığa göre seçilir ve her satıra öne çıkan veriler yazılır; havuz 24'ün altına düşerse filtreler önce danışmana söylediklerin, sonra alan, sonra il sırasıyla gevşetilir, gevşeyen seçimine uyan programlar havuzda kalır ve boşluk sıralamana en yakın diğer programlarla dolar, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal (hedef 5, kolay yerleşme seçiliyse 4), ortada dengeli (13, kolay yerleşmede 12), sonda güvenli (6, kolay yerleşmede 8) olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar, seçimi ve gerekçeleri kodla denetlenir: eksik kalan satır havuzdan tamamlanır, vaat içeren ya da başka programı anlatan gerekçenin yerine standart açıklama yazılır; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
figcaption={
<>
Havuza yalnızca en son tercih kılavuzunda yer alan programlar girer;
@@ -53,8 +54,9 @@ export function HuniSemasi() {
uyruklulara ya da meslek lisesi mezunlarına ayrılmış kontenjanlar ve
başarı sırası barajını geçemediğin bölümler önerilmez. Her
adım havuzu biraz daha daraltır; danışmana söyleyip kaydettiğin
tercihler de (dil, ücret, uzaklık gibi) havuzu süzer. Yapay zekâ
listeyi bu havuzun{" "}
tercihler de (dil, ücret, uzaklık gibi) havuzu süzer. Önceliklerin ve
özel kontenjan durumun havuzu daraltmaz: kod her programa bir uyum
puanı verir, uyanlar öne alınır. Yapay zekâ listeyi bu havuzun{" "}
<span className="font-medium text-slate-700">
dışına asla çıkmadan
</span>{" "}

View File

@@ -114,14 +114,29 @@ export function MeraklisinaIcerik() {
göre yeniden hesaplanarak listelenir; üçüncü adımdaki
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
Seçtiğin il ve devlet/vakıf tercihi, sonuç sayfasındaki ücretsiz
tabloya da uygulanır. Danışmanla konuşurken söylediğin ve
&ldquo;Kaydettiklerim&rdquo; çiplerinde duran tercihler (öğretim
dili, uzaktan ya da örgün, burs, yıllık ücret sınırı, evden
uzaklık) varsa havuzu onlar da süzer; bir çipi silersen bir
sonraki liste onsuz kurulur. Adımlar bitince elde kalan gerçek
programlar bir <strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Havuza yalnızca{" "}
tabloya da uygulanır. Üçüncü adımdaki{" "}
<strong>öncelikler</strong> (iş bulma, maaş, akademik kariyer,
prestij, kolay yerleşme) ve isteğe bağlı{" "}
<strong>özel kontenjan durumun</strong> (okul birincisi, şehit-gazi
yakını, depremzede, 34 yaş üstü kadın kontenjanı) havuzu
daraltmaz; onlar aşağıdaki uyum puanına girer. Danışmanla
konuşurken söylediğin ve &ldquo;Kaydettiklerim&rdquo; çiplerinde
duran tercihler (öğretim dili, uzaktan ya da örgün, burs, yıllık
ücret sınırı, evden uzaklık) varsa havuzu onlar da süzer; bir çipi
silersen bir sonraki liste onsuz kurulur. Adımlar bitince elde
kalan gerçek programlar bir <strong>aday havuzu</strong>{" "}
olur. Havuzdaki her programa kod bir{" "}
<strong>uyum puanı</strong> verir — yapay zekâ değil: seçtiğin
önceliklere göre TÜİK&apos;in mezun istihdamı ve kazanç grubu,
URAP araştırma sırası, programın öğretim üyesi kadrosu; özel
kontenjan durumuna uyan kontenjanı olan program; kampüs büyüklüğü
ve evden uzaklık. Verisi olmayan program cezalandırılmaz, dilim
(hayal/dengeli/güvenli) bu puandan etkilenmez; yapay zekâya giden
havuz uyum puanı ve sıralamana yakınlığa göre seçilir ve her
satıra &ldquo;öne çıkan&rdquo; veriler yazılır. &ldquo;Kolay
yerleşme&rdquo; seçiliyse listenin iskeleti 5/13/6 yerine 4/12/8
olur. Havuz yapay zekâya öyle gider: ona boş bir sayfa değil, bu
havuz verilir. Havuza yalnızca{" "}
<strong>en son tercih kılavuzunda yer alan</strong>{" "}
programlar girer: kapanmış ya da kodu değiştiği için eski kaydı
kalmış bir program sana önerilmez. Adında &ldquo;KKTC

View File

@@ -0,0 +1,173 @@
"use client";
// Danışman veriye bakarken cevabın üstünde duran araç hapları (DESIGN.md
// §4.20). Durum modeli prompt-kit'in `Tool` bileşeniyle aynıdır (AI SDK araç
// parçası: input-streaming → input-available → output-available | output-error)
// ama o bileşen ham araç adını, İngilizce rozeti ve JSON girdi/çıktıyı
// gösterir — öğrenciye araç adı ve iç kimlik gösterilmez (danisman-prompt.ts
// kuralı), bu yüzden hap burada öğrenci diliyle yazılır. Aynı araç bir cevapta
// birkaç kez çağrılırsa tek hap, yanında sayı. Haplar cevap bittikten sonra da
// kalır: danışmanın neye baktığı görünür olsun (güven). Kayıtlı geçmişte araç
// parçası tutulmadığı için haplar yalnız canlı cevapta vardır.
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, CircleAlert, Loader2 } from "lucide-react";
import type { UIMessage } from "ai";
import { Steps, StepsContent, StepsItem, StepsTrigger } from "@/components/ui/steps";
export type AracDurumu = "calisiyor" | "bitti" | "hata";
export type AracPili = {
/** Araç adı (ARAC_ETIKETI anahtarı); hap anahtarı olarak da kullanılır */
arac: string;
durum: AracDurumu;
/** Aynı aracın bu cevaptaki çağrı sayısı */
sayi: number;
};
/** Araç adı → öğrenci dili: sürerken ve bitince (araclar.ts ile aynı adlar). */
const ARAC_ETIKETI: Record<string, { surer: string; bitti: string }> = {
program_ara: { surer: "Programlara bakıyorum", bitti: "Programlara baktım" },
program_detay: { surer: "Programın ayrıntısına bakıyorum", bitti: "Programın ayrıntısına baktım" },
program_netleri: { surer: "Netlere bakıyorum", bitti: "Netlere baktım" },
program_karsilastir: { surer: "Programları karşılaştırıyorum", bitti: "Programları karşılaştırdım" },
bolum_istihdam: { surer: "Mezunların iş durumuna bakıyorum", bitti: "Mezunların iş durumuna baktım" },
bolum_ozeti: { surer: "Bölüme bakıyorum", bitti: "Bölüme baktım" },
il_ozeti: { surer: "Şehirdeki üniversitelere bakıyorum", bitti: "Şehirdeki üniversitelere baktım" },
rehber_ara: { surer: "Rehber yazılarına bakıyorum", bitti: "Rehber yazılarına baktım" },
rehber_oku: { surer: "Rehber yazısını okuyorum", bitti: "Rehber yazısını okudum" },
tercih_takvimi: { surer: "Takvime bakıyorum", bitti: "Takvime baktım" },
universite_profili: { surer: "Üniversiteye bakıyorum", bitti: "Üniversiteye baktım" },
universite_siralamasi: { surer: "Üniversite sıralamasına bakıyorum", bitti: "Üniversite sıralamasına baktım" },
kapanan_program_ara: { surer: "Kapanan programlara bakıyorum", bitti: "Kapanan programlara baktım" },
profil_guncelle: { surer: "Tercihini kaydediyorum", bitti: "Tercihini kaydettim" },
};
const VARSAYILAN_ETIKET = { surer: "Veriye bakıyorum", bitti: "Veriye baktım" };
/** Hapın metni; hata renkle değil sözle söylenir (§2.2). */
export function aracPiliMetni(p: AracPili): string {
const e = ARAC_ETIKETI[p.arac] ?? VARSAYILAN_ETIKET;
const sayi = p.sayi > 1 ? ` ×${p.sayi}` : "";
if (p.durum === "calisiyor") return `${e.surer}${sayi}…`;
if (p.durum === "hata") return `${e.bitti}${sayi} (veri gelmedi)`;
return `${e.bitti}${sayi}`;
}
/**
* Mesajın araç parçalarını araç başına tek hapa indirger: biri hâlâ sürüyorsa
* "çalışıyor", hiçbiri sürmüyor ama biri hata verdiyse "hata", yoksa "bitti".
* Sıra ilk görülme sırasıdır; haplar yer değiştirmez.
*/
export function aracPilleri(m: UIMessage): AracPili[] {
const sira: string[] = [];
const toplam = new Map<string, { calisiyor: number; hata: number; sayi: number }>();
for (const p of m.parts) {
if (!p.type.startsWith("tool-") || !("state" in p)) continue;
const arac = p.type.slice("tool-".length);
let t = toplam.get(arac);
if (!t) {
t = { calisiyor: 0, hata: 0, sayi: 0 };
toplam.set(arac, t);
sira.push(arac);
}
t.sayi += 1;
if (p.state === "output-error") t.hata += 1;
else if (p.state !== "output-available") t.calisiyor += 1;
}
return sira.map((arac) => {
const t = toplam.get(arac)!;
const durum: AracDurumu = t.calisiyor > 0 ? "calisiyor" : t.hata > 0 ? "hata" : "bitti";
return { arac, durum, sayi: t.sayi };
});
}
/** Danışman veriye bakarken görünen satır: kompakt çipler (§4.17 kalıbı, h-8). */
const PIL =
"inline-flex h-8 items-center gap-1.5 rounded-full border border-slate-200 bg-card pl-2.5 pr-3 text-xs font-semibold text-slate-700";
function DurumIkonu({ durum }: { durum: AracDurumu }) {
if (durum === "calisiyor") {
return (
<Loader2
className="size-3.5 shrink-0 animate-spin text-primary motion-reduce:animate-none"
aria-hidden
/>
);
}
if (durum === "hata") {
return <CircleAlert className="size-3.5 shrink-0 text-red-600" aria-hidden />;
}
return <Check className="size-3.5 shrink-0 text-emerald-600" aria-hidden />;
}
/**
* Birden çok araç varsa prompt-kit `Steps` düzeni (Bilal, 6 Eki 2026: "Steps de
* olsun"): başlık satırı sürerken "Veriye bakıyorum · 2/3", bitince "3 adımda
* baktım"; açılır gövdede her adım ikon + metin, solda ince çubuk. Tek araçta
* hap yeterli. Açık başlar; kullanıcı kapatabilir.
*/
function AracAdimlari({ piller }: { piller: AracPili[] }) {
const biten = piller.filter((p) => p.durum !== "calisiyor").length;
const suruyor = biten < piller.length;
return (
<Steps defaultOpen className="px-0.5">
<StepsTrigger
leftIcon={<DurumIkonu durum={suruyor ? "calisiyor" : piller.some((p) => p.durum === "hata") ? "hata" : "bitti"} />}
className="text-xs font-semibold"
aria-live="polite"
>
{suruyor ? `Veriye bakıyorum · ${biten}/${piller.length}` : `${piller.length} adımda baktım`}
</StepsTrigger>
<StepsContent>
{piller.map((p) => (
<StepsItem key={p.arac} className="flex items-center gap-1.5 text-xs font-medium text-slate-700">
<DurumIkonu durum={p.durum} />
{aracPiliMetni(p)}
</StepsItem>
))}
</StepsContent>
</Steps>
);
}
export function AracPilleri({ piller }: { piller: AracPili[] }) {
const hareketAz = useReducedMotion();
if (piller.length === 0) return null;
if (piller.length > 1) return <AracAdimlari piller={piller} />;
return (
// Odak almayan durum değişikliği ekran okuyucuya duyurulur (§10.6)
<ul role="status" aria-live="polite" className="flex flex-wrap gap-1.5 px-0.5">
<AnimatePresence initial={false}>
{piller.map((p) => (
// Hap aşağıdan gelir, bitince yerinde kalır (§6: giriş-çıkış aynı yol,
// sekmesiz yay); "hareketi azalt"ta yalnız opaklık.
<motion.li
key={p.arac}
layout={!hareketAz}
initial={hareketAz ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={hareketAz ? { opacity: 0 } : { opacity: 0, y: 4 }}
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
className={PIL}
>
{/* İkon durumla birlikte yumuşakça değişir; metin aynı yerde kalır */}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={p.durum}
initial={{ opacity: 0, scale: hareketAz ? 1 : 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: hareketAz ? 1 : 0.6 }}
transition={{ type: "spring", bounce: 0, duration: 0.2 }}
className="flex"
>
<DurumIkonu durum={p.durum} />
</motion.span>
</AnimatePresence>
{aracPiliMetni(p)}
</motion.li>
))}
</AnimatePresence>
</ul>
);
}

View File

@@ -463,7 +463,7 @@ function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
<div className="flex flex-col gap-4 p-4">
<Message className="justify-start">
{/* Liste üretimi de bir bekleme hâli: avatar, sohbetteki
"cevap yazılıyor" durumuyla aynı nabzı atar. */}
"cevap yazılıyor" durumuyla aynı animasyonu oynatır. */}
<DanismanAvatar canli />
<div className="flex max-w-[85%] flex-col gap-2">
{/* MessageContent'in class setiyle aynı balon; shimmer

View File

@@ -6,6 +6,7 @@
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { animate, useReducedMotion } from "motion/react";
import {
ArrowRight,
Bot,
@@ -21,6 +22,7 @@ import { Button } from "@/components/ui/button";
import { RaporListesi } from "./rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { tercihFarki } from "@/features/rapor/tercih-farki";
import { listeUyarilari } from "@/features/rapor/liste-uyarilari";
import { TercihDegistiModali } from "./tercih-degisti-modali";
import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
import { YurtdisiHaritalarLazy } from "@/features/liste/components/yurtdisi-haritalar-lazy";
@@ -28,6 +30,7 @@ import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporParams, RaporSonuc } from "@/features/rapor/types/rapor";
import {
SohbetClient,
SOHBET_GENISLEME_GECISI,
type DanismanOzeti,
type IlkTercihYorumu,
type Mesaj,
@@ -39,6 +42,7 @@ import {
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
import { UNIVERSITE_TIPI_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { ozelDurumOzeti } from "@/lib/ozel-durum";
import { PUAN_TURLERI } from "@/types/yokatlas";
import { DILIM_ETIKET } from "@/lib/risk";
@@ -92,6 +96,40 @@ export function ListemGovde({
const [acikSira, setAcikSira] = useState<number | null>(null);
const haritaRef = useRef<HTMLElement>(null);
// Genişletilmiş sohbet (DESIGN.md §4.20): panel boyunu kendi uzatır; burada
// ızgaranın sohbet sütunu aynı yayla büyür, liste ve harita daralan sütunda
// yeniden akar. Telefonda (lg altı) ızgara tek sütun, yalnız boy değişir.
const [sohbetGenis, setSohbetGenis] = useState(false);
const izgaraRef = useRef<HTMLDivElement>(null);
const hareketAz = useReducedMotion();
function sohbetGenisligiAyarla(genis: boolean) {
setSohbetGenis(genis);
const el = izgaraRef.current;
if (!el || !window.matchMedia("(min-width: 1024px)").matches) return;
// Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440
const taban = window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
const hedef = genis
? Math.max(taban, Math.round(Math.min(el.clientWidth * 0.6, 760)))
: taban;
const simdiki = parseFloat(el.style.getPropertyValue("--sohbet-kolon")) || taban;
const bitir = () => {
// Dar hâlde satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur
if (genis) el.style.setProperty("--sohbet-kolon", `${hedef}px`);
else el.style.removeProperty("--sohbet-kolon");
};
if (hareketAz) {
bitir();
return;
}
el.style.setProperty("--sohbet-kolon", `${simdiki}px`);
void animate(
el,
{ "--sohbet-kolon": `${hedef}px` } as Record<string, string>,
SOHBET_GENISLEME_GECISI,
).then(bitir);
}
const pinVerisi = useMemo(
() =>
pinleriTuret(
@@ -108,6 +146,10 @@ export function ListemGovde({
return `${rapor.tercihler.length} tercih: ${say("hayal")} ${DILIM_ETIKET.hayal.toLocaleLowerCase("tr-TR")} · ${say("dengeli")} ${DILIM_ETIKET.dengeli.toLocaleLowerCase("tr-TR")} · ${say("garanti")} ${DILIM_ETIKET.garanti.toLocaleLowerCase("tr-TR")}`;
}, [rapor]);
// Kapsam / tek şehir uyarıları listenin üstünde değil, danışmanın ağzından
// sohbetin açılışında verilir (Bilal, 6 Eki 2026).
const uyarilar = useMemo(() => listeUyarilari(rapor), [rapor]);
// Profil client'ta (localStorage) çözülür: ilk render'da null döner, yani
// SSR ile aynı "Tercihlerimi düzenle" hâli basılır — hidrasyon uyuşmazlığı
// olmaz, fark varsa buton sonraki render'da "güncelle"ye döner.
@@ -125,14 +167,14 @@ export function ListemGovde({
const satirlar = [
`Başarı sıralaman ${adaySira.toLocaleString("tr-TR")} ve puan türün ${PUAN_TURLERI[params.tur]}`,
hasPaket
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi"
: `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`,
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması, risk etiketi, her satırın gerekçesi ve öne çıkan verileri; listenin genel değerlendirmesi ve uyarıları"
: `Listenin açık ilk ${acikSatir} satırı (gerekçeleriyle) — kilitli tercihleri ve genel değerlendirmeyi danışman da görmüyor`,
];
satirlar.push(
"Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs)",
"Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs, özel kontenjan durumu)",
);
satirlar.push(
"Sorunca veriden okur: taban sıraları, kontenjan, ücret/burs, dil, akreditasyon, koşullar, üniversite künyesi",
"Sorunca veriden okur: yıllara göre taban sırası, puan, kontenjan ve yerleşen; kontenjan türleri; ücret/burs, dil, akreditasyon, kadro, koşullar; netler; mezun istihdamı (TÜİK); üniversite künyesi ve URAP sıralaması; kapanan programlar; rehber yazıları",
);
if (params.kategoriler?.length) {
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
@@ -148,6 +190,9 @@ export function ListemGovde({
if (params.oncelikler?.length) {
satirlar.push(`Önceliklerin: ${params.oncelikler.join(", ")}`);
}
if (params.ozelDurum?.length) {
satirlar.push(`Özel kontenjan durumun: ${ozelDurumOzeti(params.ozelDurum)}`);
}
satirlar.push("Bu sohbetteki son mesajların");
return satirlar;
}, [adaySira, params, hasPaket, acikSatir]);
@@ -167,7 +212,13 @@ export function ListemGovde({
);
return (
<div className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
// Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = SOHBET_KOLON_TABANI);
// "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla), liste
// ve harita daralan 1fr sütunda kendini yeniden akıtır. DESIGN.md §4.20.
<div
ref={izgaraRef}
className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:[--sohbet-kolon:380px] lg:grid-cols-[minmax(0,1fr)_var(--sohbet-kolon)] xl:[--sohbet-kolon:440px]"
>
<div className="flex min-w-0 flex-col gap-6">
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
<section
@@ -330,8 +381,11 @@ export function ListemGovde({
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
) : (
<div className="flex flex-col gap-6">
@@ -340,11 +394,14 @@ export function ListemGovde({
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
className="h-[480px]"
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
<section

View File

@@ -46,6 +46,7 @@ import {
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import { ProgramAyrintiButonu } from "@/features/katalog/components/program-ayrinti";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import {
DILIM_ETIKET,
@@ -79,6 +80,7 @@ export function RaporSatirDetayi({
gerekce,
riskNotu,
trendOzeti,
oneCikan,
}: {
dilim: string;
risk: RiskSeviyesi;
@@ -86,6 +88,8 @@ export function RaporSatirDetayi({
gerekce: string;
riskNotu: string;
trendOzeti: string;
/** Koddan hesaplanan "öne çıkan" veriler (uyum puanı); yoksa satır çizilmez */
oneCikan?: string[];
}) {
const stil = RISK_STIL[risk];
// Risk etiketi yalnızca dilimin varsayılanından farklıysa yazılır; yoksa
@@ -109,6 +113,12 @@ export function RaporSatirDetayi({
Neden listede?
</dt>
<dd className="mt-1 text-slate-600">{gerekce}</dd>
{oneCikan && oneCikan.length > 0 ? (
<dd className="mt-2 text-sm text-slate-500">
<span className="font-semibold text-slate-700">Öne çıkan:</span>{" "}
{oneCikan.join(" · ")}
</dd>
) : null}
</div>
<div className="rounded-xl border border-slate-200 bg-card p-4">
<dt className="text-sm font-semibold text-slate-900">
@@ -170,15 +180,6 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const tekIl = useMemo(() => {
const iller = new Set<string>();
for (const t of rapor.tercihler) {
const il = rapor.programlar[t.programId]?.il;
if (il) iller.add(il);
}
return iller.size === 1 ? [...iller][0] : null;
}, [rapor]);
// Sütun başlığı kaydın KENDİ penceresini söyler: eski raporların sıra
// geçmişi üretildiği günün yıl sayısını taşır (dizi doldurulmaz), başlık
// çizgide olmayan bir yılı vaat etmesin. Geçmişi hiç olmayan kayıtta
@@ -356,18 +357,27 @@ export function RaporListesi({
gerekce={t.gerekce}
riskNotu={t.riskNotu}
trendOzeti={t.trendOzeti}
oneCikan={t.oneCikan}
/>
)}
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-2 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"
>
<MapPin className="size-4" aria-hidden />
Haritada gör
</button>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-x-5">
<ProgramAyrintiButonu
programId={t.programId}
isim={p?.isim ?? t.programId}
universite={p?.universite}
varyant="metin"
/>
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
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"
>
<MapPin className="size-4" aria-hidden />
Haritada gör
</button>
) : null}
</div>
</TableCell>
</TableRow>
) : null}
@@ -383,23 +393,8 @@ export function RaporListesi({
</p>
) : null}
{rapor.kapsam?.uyarlandi ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-relaxed text-amber-900">
Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "}
{rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "}
dengeli · {rapor.kapsam.dagilim.garanti} güvenli olarak kuruldu. Daha
fazla seçenek için alan veya il filtreni genişletebilirsin.
</p>
) : null}
{tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-relaxed text-amber-900">
Listenin {rapor.tercihler.length} tercihi de tek şehirde (
{trBaslikDuzeni(tekIl)}). Boşta kalma riskini azaltmak için
birkaç farklı şehir eklemeyi düşünebilirsin.
</p>
) : null}
{/* Kapsam ve tek şehir uyarıları burada çizilmez: danışman sohbetinin
açılışında "Danışman uyarısı" olarak verilir (liste-uyarilari.ts). */}
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-card">
<Table>
<TableHeader>

View File

@@ -0,0 +1,372 @@
"use client";
// Danışman cevabındaki bağlantılar (DESIGN.md §4.20). Site içi adres
// (/rehber/…, /universite/…, /bolum/…, /universiteler/<il>, /paket, /listem)
// prompt-kit'in `Source` bileşenindeki gibi KÜÇÜK bir satır içi hap olarak
// çizilir: türünü söyleyen ikon + sayfanın adı, hover tek turuncu (§2.1).
// Markdown'da `[Başlık](/rehber/slug)` biçimi beklenir (danisman-prompt.ts
// kuralı); model adresi düz metin ya da backtick içinde yazarsa aynı hap, adı
// slug'dan türetilerek çizilir — Bilal, 6 Eki 2026: "chat link verdiğinde kod
// gibi veriyor". Dış bağlantı (https://…) yeni sekmede açılan düz linktir.
//
// Üzerine gelince (yalnız fare/iz sürücü olan cihazlarda) imleci izleyen bir
// SAYFA ÖNİZLEMESİ açılır: aramadaki mini tarayıcı penceresi dilinde
// (katalog-arama.tsx `KESFET_TONLARI`) bir pencere — üç nokta, adres hapı,
// içinde rehberde yalnız kapak (tam 1200×630 ölçeklenir), diğerlerinde
// logo/ikon + başlık + özet. Veri
// /api/onizleme'den gelir, hap başına bir kez çekilir. Hareket motion-primitives
// `Cursor` kalıbı (Bilal'in verdiği örnek): imleç yaylı izlenir, pencere
// yükseklik 0 → auto, ölçek 0,3 → 1 ile açılır, aynı yoldan kapanır;
// hareketi azalt'ta yalnız opaklık ve izleme yok. Dokunmatikte önizleme yok.
// prompt-kit `Source` birebir alınmadı: Google favicon'u ve `muted` paleti
// dış siteler içindir.
import {
useCallback,
useRef,
useState,
type ComponentProps,
type ComponentType,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import {
BookOpen,
Coins,
ExternalLink,
GraduationCap,
Landmark,
ListChecks,
MapPin,
type LucideIcon,
} from "lucide-react";
import type { Components, ExtraProps } from "react-markdown";
import type { SayfaOnizlemesi } from "@/app/api/onizleme/route";
import { INITIAL_COMPONENTS } from "@/components/ui/markdown";
import { UniLogo } from "@/components/uni-logo";
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
import { cn } from "@/lib/utils";
/** Site içi adres: kök yol ya da canlı alan adıyla yazılmış hâli. */
const SITE_KOKLERI = ["https://kolaytercih.com", "https://www.kolaytercih.com"];
/** Metinde geçen çıplak site içi adres (bağlantı/backtick dışında). */
const CIPLAK_ADRES =
/(^|[^\w/(\]`])(\/(?:rehber|universite|bolum|universiteler)\/[a-z0-9-]+|\/(?:paket|listem|sonuc|meraklisina|bolumler|universiteler))(?![\w/?-])/g;
export function icYolaCevir(href: string): string | null {
const h = href.trim();
for (const kok of SITE_KOKLERI) {
if (h.startsWith(kok)) return h.slice(kok.length) || "/";
}
if (h.startsWith("/") && !h.startsWith("//")) return h;
return null;
}
type Tur = {
ikon: LucideIcon;
/** Ekran okuyucu ve title için: "Rehber yazısı: …" */
etiket: string;
/** Önizleme verisi var mı (api/onizleme bu yolu tanır) */
onizleme: boolean;
};
const TURLER: { desen: RegExp; tur: Tur }[] = [
{ desen: /^\/rehber\/[a-z0-9-]+$/, tur: { ikon: BookOpen, etiket: "Rehber yazısı", onizleme: true } },
{ desen: /^\/universite\/[a-z0-9-]+$/, tur: { ikon: Landmark, etiket: "Üniversite sayfası", onizleme: true } },
{ desen: /^\/bolum\/[a-z0-9-]+$/, tur: { ikon: GraduationCap, etiket: "Bölüm sayfası", onizleme: true } },
{ desen: /^\/universiteler\/[a-z0-9-]+$/, tur: { ikon: MapPin, etiket: "Şehir sayfası", onizleme: true } },
{ desen: /^\/paket/, tur: { ikon: Coins, etiket: "Paket", onizleme: false } },
{ desen: /^\/listem/, tur: { ikon: ListChecks, etiket: "Listem", onizleme: false } },
];
function turunu(yol: string): Tur {
return (
TURLER.find((t) => t.desen.test(yol))?.tur ?? { ikon: ExternalLink, etiket: "Sayfa", onizleme: false }
);
}
/** Başlıksız adres için slug'dan okunur ad: "vakif-universitesi-bursu-kesilir-mi" → "Vakif üniversitesi bursu kesilir mi". */
export function slugBasligi(yol: string): string {
const slug = yol.split(/[?#]/)[0].split("/").filter(Boolean).pop() ?? yol;
const ad = slug.replace(/-/g, " ").replace(/universite/g, "üniversite").trim();
return ad ? ad.charAt(0).toLocaleUpperCase("tr-TR") + ad.slice(1) : yol;
}
/** Çıplak site içi adresleri markdown bağlantısına çevirir; hazır bağlantı ve backtick'e dokunmaz. */
export function baglantilariIsaretle(metin: string): string {
return metin.replace(CIPLAK_ADRES, (_m, once: string, yol: string) => `${once}[${yol}](${yol})`);
}
// ── Önizleme verisi (hap başına bir kez) ────────────────────────────────────
const onizlemeOnbellek = new Map<string, Promise<SayfaOnizlemesi | null>>();
function onizlemeGetir(yol: string): Promise<SayfaOnizlemesi | null> {
let p = onizlemeOnbellek.get(yol);
if (!p) {
p = fetch(`/api/onizleme?yol=${encodeURIComponent(yol)}`)
.then((r) => (r.ok ? (r.json() as Promise<SayfaOnizlemesi>) : null))
.catch(() => null);
onizlemeOnbellek.set(yol, p);
}
return p;
}
/** Fare/iz sürücü var mı; dokunmatikte önizleme açılmaz. */
function hoverVar(): boolean {
return typeof window !== "undefined" && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
// ── Pencere (mini tarayıcı) ─────────────────────────────────────────────────
const PENCERE_GENISLIK = 288;
const KAPAK_GENISLIK = 1200;
const KAPAK_YUKSEKLIK = 630;
const KAPAK_OLCEK = PENCERE_GENISLIK / KAPAK_GENISLIK;
const KAPAK_GOSTERIM_YUKSEKLIK = KAPAK_YUKSEKLIK * KAPAK_OLCEK;
/** Rehber: çubuk 24 + ölçekli kapak. Diğer türler metinli gövde. */
const PENCERE_YUKSEKLIK_TAHMINI = 24 + KAPAK_GOSTERIM_YUKSEKLIK;
const IMLEC_PAYI = 18;
/** Tam kapak (1200×630) pencereye ölçeklenir — compact/vw kesmez, tüm yazı ve logo görünür. */
function OlcekliKapak({ baslik }: { baslik: string }) {
return (
<div
className="relative overflow-hidden"
style={{ width: PENCERE_GENISLIK, height: KAPAK_GOSTERIM_YUKSEKLIK }}
>
<div
className="absolute left-0 top-0 origin-top-left"
style={{
width: KAPAK_GENISLIK,
height: KAPAK_YUKSEKLIK,
transform: `scale(${KAPAK_OLCEK})`,
}}
>
<RehberKapak
baslik={baslik}
className="h-full rounded-none border-0"
/>
</div>
</div>
);
}
function PencereIcerigi({ yol, baslik, veri }: { yol: string; baslik: ReactNode; veri: SayfaOnizlemesi | null | undefined }) {
const tur = turunu(yol);
const Ikon = tur.ikon;
const gorunenBaslik = veri?.baslik ?? (typeof baslik === "string" ? baslik : slugBasligi(yol));
const rehber = tur.etiket === "Rehber yazısı";
return (
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-card shadow-lg shadow-slate-900/5">
{/* Pencere çubuğu: aramadaki mini tarayıcıyla aynı dil (üç nokta + adres hapı) */}
<div className="flex h-6 shrink-0 items-center gap-1 border-b border-slate-100/90 bg-slate-50/90 px-2.5">
<span className="size-2 rounded-full bg-slate-300" />
<span className="size-2 rounded-full bg-slate-300 opacity-65" />
<span className="size-2 rounded-full bg-slate-300 opacity-35" />
<span className="ml-2 flex h-4 min-w-0 flex-1 items-center rounded-full bg-card px-2 text-xs text-slate-500 shadow-[0_0_0_1px_rgba(15,23,42,0.06)]">
<span className="truncate">kolaytercih.com{yol}</span>
</span>
</div>
{rehber ? (
<OlcekliKapak baslik={gorunenBaslik} />
) : (
<div className="p-3">
{veri?.gorsel && veri.tur === "universite" ? (
<div className="mb-3 flex items-center gap-3">
<UniLogo slug={yol.split("/").pop()} ad={gorunenBaslik} boy="md" />
</div>
) : (
<div className="mb-3 flex size-11 items-center justify-center rounded-xl bg-primary/10">
<Ikon className="size-5 text-primary" aria-hidden />
</div>
)}
<p className="font-heading text-sm font-bold leading-snug text-slate-900 line-clamp-2">
{gorunenBaslik}
</p>
{veri ? (
<>
<p className="mt-1 text-xs leading-5 text-slate-500 line-clamp-3">{veri.aciklama}</p>
{veri.meta.length ? (
<p className="mt-2 text-xs font-medium text-slate-500">{veri.meta.join(" · ")}</p>
) : null}
</>
) : (
<p className="mt-1 text-xs leading-5 text-slate-500">{tur.etiket}</p>
)}
</div>
)}
</div>
);
}
/**
* İmleci izleyen önizleme (motion-primitives `Cursor` kalıbı). Body'ye
* portal'lanır: sohbet kabı overflow-hidden ve pencere kabın dışına taşar.
*/
function ImlecOnizlemesi({
acik,
goruldu,
x,
y,
yol,
baslik,
veri,
}: {
acik: boolean;
/** İlk hover'dan sonra true: portal yalnız istemcide, olaydan sonra kurulur (effect'te setState yok) */
goruldu: boolean;
x: ReturnType<typeof useSpring>;
y: ReturnType<typeof useSpring>;
yol: string;
baslik: ReactNode;
veri: SayfaOnizlemesi | null | undefined;
}) {
const hareketAz = useReducedMotion();
if (!goruldu || typeof document === "undefined") return null;
return createPortal(
<AnimatePresence>
{acik ? (
<motion.div
aria-hidden
style={{ x, y, width: PENCERE_GENISLIK }}
className="pointer-events-none fixed left-0 top-0 z-[60]"
>
<motion.div
initial={hareketAz ? { opacity: 0 } : { height: 0, opacity: 0, scale: 0.3 }}
animate={hareketAz ? { opacity: 1 } : { height: "auto", opacity: 1, scale: 1 }}
exit={hareketAz ? { opacity: 0 } : { height: 0, opacity: 0, scale: 0.3 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="origin-top-left overflow-hidden"
>
<PencereIcerigi yol={yol} baslik={baslik} veri={veri} />
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
// ── Hap ─────────────────────────────────────────────────────────────────────
/** Küçük satır içi hap (Bilal, 6 Eki 2026: ilk sürüm "çok yer kaplıyor"). */
const HAP =
"inline-flex h-6 max-w-full cursor-pointer items-center gap-1 rounded-full border border-slate-200 bg-card pl-1.5 pr-2 align-middle text-xs font-medium text-primary no-underline 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.97]";
export function SohbetBaglantisi({
href,
children,
className,
}: {
href: string;
children?: ReactNode;
className?: string;
}) {
const yol = icYolaCevir(href);
const hareketAz = useReducedMotion();
const [acik, setAcik] = useState(false);
const [goruldu, setGoruldu] = useState(false);
const [veri, setVeri] = useState<SayfaOnizlemesi | null | undefined>(undefined);
const yuklendi = useRef(false);
// İmleç konumu: ham değer → yay (hafif gecikmeyle izler, Cursor kalıbı)
const hamX = useMotionValue(0);
const hamY = useMotionValue(0);
const yay = { stiffness: 450, damping: 40, mass: 0.5 };
const x = useSpring(hamX, yay);
const y = useSpring(hamY, yay);
const konumla = useCallback(
(e: ReactPointerEvent, zipla: boolean) => {
// Pencere imlecin sağ altında; kenara sığmazsa sola / üste alınır
let px = e.clientX + IMLEC_PAYI;
let py = e.clientY + IMLEC_PAYI;
if (px + PENCERE_GENISLIK > window.innerWidth - 8) px = e.clientX - IMLEC_PAYI - PENCERE_GENISLIK;
if (py + PENCERE_YUKSEKLIK_TAHMINI > window.innerHeight - 8) py = e.clientY - IMLEC_PAYI - PENCERE_YUKSEKLIK_TAHMINI;
if (zipla || hareketAz) {
hamX.jump(px);
hamY.jump(py);
} else {
hamX.set(px);
hamY.set(py);
}
},
[hamX, hamY, hareketAz],
);
if (!yol) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-medium text-primary underline underline-offset-2 transition-colors hover:text-orange-500",
className,
)}
>
{children}
</a>
);
}
const tur = turunu(yol);
// Bağlantı metni adresin kendisiyse (çıplak ya da backtick'li adres) adı slug'dan türet
const metin =
typeof children === "string" && icYolaCevir(children) === yol ? slugBasligi(yol) : children;
const girdi = (e: ReactPointerEvent) => {
if (!tur.onizleme || !hoverVar()) return;
konumla(e, true);
setGoruldu(true);
setAcik(true);
if (!yuklendi.current) {
yuklendi.current = true;
void onizlemeGetir(yol).then(setVeri);
}
};
return (
<>
<Link
href={yol}
className={cn(HAP, className)}
title={tur.etiket}
onPointerEnter={girdi}
onPointerMove={(e) => acik && konumla(e, false)}
onPointerLeave={() => setAcik(false)}
onFocus={() => setAcik(false)}
>
<tur.ikon className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{metin ?? slugBasligi(yol)}</span>
<span className="sr-only"> ({tur.etiket})</span>
</Link>
{tur.onizleme ? (
<ImlecOnizlemesi acik={acik} goruldu={goruldu} x={x} y={y} yol={yol} baslik={metin} veri={veri} />
) : null}
</>
);
}
type KodProps = ComponentProps<"code"> & ExtraProps;
const VarsayilanKod = INITIAL_COMPONENTS.code as ComponentType<KodProps>;
function InlineKod(props: KodProps) {
// Backtick içine alınmış site içi adres de haptır; gerisi prompt-kit'in satır içi kodu
const metin = typeof props.children === "string" ? props.children.trim() : null;
const yol = metin ? icYolaCevir(metin) : null;
if (yol && /^\/[a-z]/.test(yol) && !/\s/.test(metin ?? "")) {
return <SohbetBaglantisi href={yol}>{yol}</SohbetBaglantisi>;
}
return <VarsayilanKod {...props} />;
}
/** Danışman balonunun markdown bileşenleri: prompt-kit varsayılanı + bağlantı hapları. */
export const SOHBET_MARKDOWN_BILESENLERI: Partial<Components> = {
...INITIAL_COMPONENTS,
a: ({ href, children }) => <SohbetBaglantisi href={href ?? "#"}>{children}</SohbetBaglantisi>,
code: InlineKod as Components["code"],
};

View File

@@ -5,11 +5,23 @@ import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type ChatTransport, type UIMessage } from "ai";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2, X } from "lucide-react";
import { animate, useReducedMotion } from "motion/react";
import {
ArrowUp,
Coins,
Info,
Lock,
Maximize2,
Minimize2,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { AracPilleri, aracPilleri } from "./arac-pilleri";
import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti";
import { Button } from "@/components/ui/button";
import {
ChatContainerContent,
@@ -67,31 +79,41 @@ function mesajMetni(m: UIMessage): string {
.join("\n\n");
}
/** Danışman veriye bakarken görünen satır (araç adı → öğrenci dili). */
const ARAC_ETIKETI: Record<string, string> = {
program_ara: "Programlara bakıyorum",
program_detay: "Programın ayrıntısına bakıyorum",
program_netleri: "Netlere bakıyorum",
program_karsilastir: "Programları karşılaştırıyorum",
bolum_istihdam: "Mezunların iş durumuna bakıyorum",
bolum_ozeti: "Bölüme bakıyorum",
il_ozeti: "Şehirdeki üniversitelere bakıyorum",
rehber_ara: "Rehber yazılarına bakıyorum",
tercih_takvimi: "Takvime bakıyorum",
universite_profili: "Üniversiteye bakıyorum",
profil_guncelle: "Tercihini kaydediyorum",
/** Soru gitti, cevabın ilk parçası henüz gelmedi: listede cevabın yerini
* tutan boş danışman mesajı. */
const BEKLENEN_CEVAP: UIMessage = {
id: "beklenen-cevap",
role: "assistant",
parts: [],
};
/** Şu an çalışan (sonucu henüz gelmemiş) araçların etiketleri. */
function calisanAraclar(m: UIMessage): string[] {
const etiketler = m.parts.flatMap((p) => {
if (!p.type.startsWith("tool-") || !("state" in p)) return [];
if (p.state === "output-available" || p.state === "output-error") return [];
return [ARAC_ETIKETI[p.type.slice("tool-".length)] ?? "Veriye bakıyorum"];
});
return [...new Set(etiketler)];
/** Danışman cevabının liste anahtarı: kendi kimliği değil, cevapladığı sorunun
* kimliği. Böylece bekleme satırı (BEKLENEN_CEVAP) ile sonradan gelen gerçek
* cevap React için aynı satırdır — avatarın animasyonu ve iskelet, ilk parça
* geldiğinde baştan başlamaz. */
function cevapAnahtari(mesajlar: UIMessage[], i: number): string {
const soru = mesajlar[i - 1];
return soru?.role === "user" ? `cevap-${soru.id}` : mesajlar[i].id;
}
// Araç adı → öğrenci dili eşlemesi ve "…bakıyorum" hapları arac-pilleri.tsx'te.
/** Panelin boyut geçişi: sekmesiz yay (DESIGN.md §6); listem-govde ızgarası aynı yayı kullanır. */
export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.35 } as const;
/**
* Genişletilmiş panel üstten `top-28` (7rem; listem sütununun sticky ofseti)
* hizasına kaydırılır ve altı site geneli alt progressive blur'un
* (`.kt-sayfa-blur-alt`, 7rem; globals.css) üstünde biter — Bilal, 6 Eki 2026:
* "aşağı kısmı progressive blura takılıyor". Sayfa o kadar kaymıyorsa kalan
* fark boydan düşülür; panel hiçbir durumda bulanık şeride girmez.
*/
const USTE_UZAKLIK_REM = 7;
const ALT_BLUR_REM = 7;
const GENIS_EN_AZ_PX = 380;
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
const GENIS_YUKSEKLIK = "";
type Red = { status: number; error?: string };
/**
@@ -157,8 +179,8 @@ export interface IlkTercihYorumu {
/** Danışman panellerinin ortak başlığı: animasyonlu logo + site header'daki
* KolayTercih wordmark deseninde "DanışmanlaKonuş" (ilk kelime ince, ikinci
* kalın, boşluksuz). İşaret ortak AnimasyonluMark bileşeni — header
* logosuyla aynı nabız animasyonu, burada sürekli atar. */
* kalın, boşluksuz). İşaret ortak AnimasyonluMark bileşeninin yerinde
* nabız varyantı, burada sürekli atar. */
export function DanismanBaslik() {
return (
// gap yok denecek kadar az (0.5): SVG viewBox'ının kendi iç boşluğu
@@ -174,25 +196,33 @@ export function DanismanBaslik() {
}
/** Danışman mesajlarının avatarı — KolayTercih markası. `canli` iken (cevap
* beklenirken/yazılırken) statik görsel yerine başlıktaki nabız animasyonu
* oynar: danışmanın "düşündüğü" avatardan okunur. */
export function DanismanAvatar({ canli = false }: { canli?: boolean }) {
* beklenirken/yazılırken) panel başlığındaki DanışmanlaKonuş işareti gibi
* yerinde nabız atar: danışmanın "düşündüğü" avatardan okunur. `aracta` iken
* (danışman veriye bakıyor, "…bakıyorum" hapı dönüyor) kareler sırayla
* birbirinin yerine yürür; araç bitince nabza döner. Her geçişte ve cevap
* bitince kareler yumuşakça yerine oturur.
* `renk="turuncu"`: panel başlığındaki DanışmanlaKonuş işaretinin rengi —
* yalnız "Danışman uyarısı" balonunda (durağan; hareket "düşünüyor" demek). */
export function DanismanAvatar({
canli = false,
aracta = false,
renk = "marka",
}: {
canli?: boolean;
aracta?: boolean;
renk?: "marka" | "turuncu";
}) {
return (
<span
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-card"
aria-hidden
>
{canli ? (
<AnimasyonluMark varyant="nabiz" className="size-5" />
) : (
<Image
src="/kolay-tercih-mark.svg"
alt=""
width={32}
height={32}
className="size-5"
/>
)}
<AnimasyonluMark
oynat={canli}
varyant={canli && aracta ? "zincir" : "nabiz"}
renk={renk}
className="size-5"
/>
</span>
);
}
@@ -224,10 +254,14 @@ export function SohbetClient({
initialMesajlar,
className,
ozet,
uyarilar,
ilkYorum,
bildikleri,
initialProfil,
denemeTransport,
genis: genisProp,
onGenisDegistir,
genisYukseklik = GENIS_YUKSEKLIK,
}: {
baslangicKredi: number;
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
@@ -238,6 +272,10 @@ export function SohbetClient({
className?: string;
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
ozet?: DanismanOzeti;
/** Listenin kendisinden çıkan uyarılar (liste-uyarilari.ts: dağılım havuza
* uyarlandı, tüm tercihler tek şehirde). Özetin altında turuncu işaretli
* "Danışman uyarısı" balonu olarak durur; sohbet ilerlese de kalır. */
uyarilar?: string[];
/** 1. tercihin Yapay Zeka yorumu; sohbet boşken (taze kurulmuş liste)
* özetin altında açılış balonu olarak durur — üretim panelindeki "ilk
* tercihinin yorumunu burada okuyacaksın" sözünün karşılığı. */
@@ -248,6 +286,13 @@ export function SohbetClient({
/** Yalnız /dev/sohbet: /api/soru yerine önceden yazılmış senaryoyu oynatır
* (model, oturum ve kredi yok). Üretim yüzeyleri bunu vermez. */
denemeTransport?: ChatTransport<UIMessage>;
/** Genişletilmiş hâl dışarıdan yönetiliyorsa (listem ızgarası sütunu da
* büyütür); verilmezse panel kendi durumunu tutar ve yalnız boyu uzar. */
genis?: boolean;
onGenisDegistir?: (genis: boolean) => void;
/** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır
* (üst 7rem sticky hizası, alt 7rem progressive blur payı). */
genisYukseklik?: string;
}) {
const [girdi, setGirdi] = useState("");
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
@@ -259,6 +304,62 @@ export function SohbetClient({
// yeniden çekilir: model profil_guncelle çağırdıysa yeni çip burada belirir.
const [profil, setProfil] = useState<ProfilSatiri[]>(initialProfil ?? []);
const router = useRouter();
// Genişletilmiş sohbet (Bilal, 6 Eki 2026: "div'in smooth genişlemesi, diğer
// alanların kendini yeniden boyutlandırması"): panel YERİNDE büyür — perde,
// tam ekran ya da ayrı pencere yok. Boy burada yaylı geçişle uzar; listem
// ızgarası (listem-govde.tsx) aynı anda sohbet sütununu genişletir, liste ve
// harita daralan sütunda kendini yeniden akıtır. useChat durumu ve kaydırma
// korunur. Hareketi azalt: geçişsiz.
const [genisIc, setGenisIc] = useState(false);
const genis = genisProp ?? genisIc;
const kapRef = useRef<HTMLElement>(null);
const hareketAz = useReducedMotion();
function genisligiDegistir() {
const yeni = !genis;
if (genisProp === undefined) setGenisIc(yeni);
onGenisDegistir?.(yeni);
const el = kapRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const eski = rect.height;
let hedefYukseklik = "";
if (yeni) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const usteUzaklik = USTE_UZAKLIK_REM * rem;
// Panelin üstünü sticky noktasına getir; sayfa o kadar kaymıyorsa kalan
// fark panelin üstünde kalır ve boydan düşülür
const kayabilir = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight - window.scrollY,
);
const kaydir = Math.min(Math.max(0, rect.top - usteUzaklik), kayabilir);
const kalanUst = rect.top - kaydir;
const hedefPx = Math.max(
GENIS_EN_AZ_PX,
Math.round(window.innerHeight - kalanUst - ALT_BLUR_REM * rem),
);
hedefYukseklik = genisYukseklik || `${hedefPx}px`;
if (kaydir > 0) {
const basla = window.scrollY;
if (hareketAz) window.scrollTo(0, basla + kaydir);
else
void animate(basla, basla + kaydir, {
...SOHBET_GENISLEME_GECISI,
onUpdate: (v) => window.scrollTo(0, v),
});
}
}
// Boy geçişi: hedefi ölçüp eski değerden hedefe aynı yayla git; dar hâle
// dönünce satır içi değer kalkar, sınıftaki yükseklik yeniden geçerli olur
el.style.height = hedefYukseklik;
if (hareketAz) return;
const hedef = el.getBoundingClientRect().height;
el.style.height = `${eski}px`;
void animate(el, { height: hedef }, SOHBET_GENISLEME_GECISI).then(() => {
el.style.height = hedefYukseklik;
});
}
// useChat geri çağrıları ilk render'da yakalanır; güncel değerler ref'ten okunur.
const profilRef = useRef(profil);
@@ -299,6 +400,10 @@ export function SohbetClient({
},
});
const bekliyor = status === "submitted" || status === "streaming";
const gorunenMesajlar =
status === "submitted" && mesajlar.at(-1)?.role === "user"
? [...mesajlar, BEKLENEN_CEVAP]
: mesajlar;
/**
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
@@ -457,7 +562,10 @@ export function SohbetClient({
return (
<section
ref={kapRef}
aria-label="Danışman sohbeti"
// Yükseklik: sınıftan (dar hâl); genişletilince satır içi stil
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
// 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
@@ -471,6 +579,20 @@ export function SohbetClient({
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
<DanismanBaslik />
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={genisligiDegistir}
aria-pressed={genis}
aria-label={genis ? "Sohbeti daralt" : "Sohbeti genişlet"}
title={genis ? "Daralt" : "Genişlet"}
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
{genis ? (
<Minimize2 className="size-3.5" aria-hidden />
) : (
<Maximize2 className="size-3.5" aria-hidden />
)}
</button>
{bildikleri?.length ? (
<TooltipProvider delayDuration={120}>
<Tooltip>
@@ -607,6 +729,28 @@ export function SohbetClient({
</div>
</Message>
) : null}
{uyarilar?.length ? (
<Message className="justify-start">
<DanismanAvatar renk="turuncu" />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="text-xs font-semibold text-slate-500">
{uyarilar.length > 1
? "Danışman uyarıları"
: "Danışman uyarısı"}
</p>
{uyarilar.map((u) => (
// Zemin başlıktaki DanışmanlaKonuş işaretinin turuncusu
// (orange-500); yazı beyaz ve font-medium (§4.20).
<MessageContent
key={u}
className="bg-orange-500 font-medium text-white"
>
{u}
</MessageContent>
))}
</div>
</Message>
) : null}
{ilkYorum && mesajlar.length === 0 ? (
<Message className="justify-start">
<DanismanAvatar />
@@ -682,7 +826,7 @@ export function SohbetClient({
</div>
) : (
<>
{mesajlar.map((m, i) => {
{gorunenMesajlar.map((m, i) => {
const metin = mesajMetni(m);
if (m.role === "user") {
return (
@@ -693,48 +837,40 @@ export function SohbetClient({
</Message>
);
}
const canli = bekliyor && i === mesajlar.length - 1;
// Danışman veriye bakarken ne yaptığını söyler; sonuç
// gelince satır kalkar, cevap metni akar.
const araclar = canli ? calisanAraclar(m) : [];
const canli = bekliyor && i === gorunenMesajlar.length - 1;
// Danışman veriye bakarken ne yaptığını hap olarak söyler
// (arac-pilleri.tsx); haplar cevap bitince de kalır.
const piller = aracPilleri(m);
return (
<Message key={m.id} className="justify-start">
<Message
key={cevapAnahtari(gorunenMesajlar, i)}
className="justify-start"
>
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız animasyonuna geçer. */}
<DanismanAvatar canli={canli} />
nabız atar; bir araç çalışırken kareler dolaşır. */}
<DanismanAvatar
canli={canli}
aracta={piller.some((p) => p.durum === "calisiyor")}
/>
<div className="flex w-full max-w-[85%] flex-col gap-2">
<AracPilleri piller={piller} />
{metin ? (
// Site içi adresler bağlantı hapı olur (sohbet-baglanti.tsx):
// markdown bağlantısı, backtick içi ve çıplak adres aynı hap
<MessageContent
markdown
components={SOHBET_MARKDOWN_BILESENLERI}
className="bg-slate-100 text-slate-800 prose-sm"
>
{metin}
{baglantilariIsaretle(metin)}
</MessageContent>
) : (
<CevapIskeleti />
)}
{araclar.length > 0 ? (
<p
role="status"
className="px-1 text-xs font-medium text-slate-500"
>
{araclar.join(" · ")}…
</p>
) : null}
</div>
</Message>
);
})}
{/* Soru gitti, cevabın ilk parçası henüz gelmedi */}
{status === "submitted" &&
mesajlar[mesajlar.length - 1]?.role === "user" ? (
<Message className="justify-start">
<DanismanAvatar canli />
<div className="w-full max-w-[85%]">
<CevapIskeleti />
</div>
</Message>
) : null}
</>
)}
<ChatContainerScrollAnchor />

View File

@@ -139,4 +139,26 @@ export const SENARYOLAR: Senaryo[] = [
})
.transport({ delayMs: 25 }),
},
{
ad: "Bağlantılar",
aciklama: "Üç biçimde site içi adres: markdown bağlantısı, backtick içinde, çıplak; dış bağlantı.",
kredi: 12,
transport: () =>
createChat()
.user("vakıf bursu kesilir mi, hangi yazıya bakayım?")
.sleep(500)
.assistant(({ writer }) => {
writer
.tool("rehber_ara", { input: { konu: "vakıf bursu kesilir mi" } })
.sleep(900)
.output({ sonuclar: [{ baslik: "Vakıf Üniversitesi Bursu Kesilir mi?", sayfa: "/rehber/vakif-universitesi-bursu-kesilir-mi" }] });
writer.text(
"Kuralı anlatan yazı: [Vakıf Üniversitesi Bursu Kesilir mi?](/rehber/vakif-universitesi-bursu-kesilir-mi). " +
"Üniversite sayfası `/universite/koc-universitesi` ve bölüm sayfası /bolum/hukuk da yardımcı olur; " +
"resmî kaynak için [ÖSYM](https://www.osym.gov.tr).\n\n" +
"Not: taban sıraları her yıl değişir.",
);
})
.transport({ delayMs: 20 }),
},
];

View File

@@ -0,0 +1,36 @@
// Listenin kendisinden (koddan) çıkan uyarılar: havuz dar geldiği için
// dağılımın ideal 5/13/6'dan sapması ve tüm tercihlerin tek şehirde olması.
// Yapay Zeka üretmez, sayılar kayıttan okunur. Danışman sohbetinin açılışında
// "Danışman uyarısı" balonu olarak gösterilir (DESIGN.md §4.20); listenin
// üstünde ayrı bir sarı kutu olarak çizilmez.
import { DILIM_ETIKET } from "@/lib/risk";
import { trBaslikDuzeni } from "@/lib/slug";
import type { RaporSonuc } from "./types/rapor";
const kucuk = (etiket: string) => etiket.toLocaleLowerCase("tr-TR");
export function listeUyarilari(rapor: RaporSonuc): string[] {
const uyarilar: string[] = [];
const kapsam = rapor.kapsam;
if (kapsam?.uyarlandi) {
uyarilar.push(
`Tercihlerine göre ${kapsam.toplam} uygun program bulundu; listen ${kapsam.dagilim.hayal} ${kucuk(DILIM_ETIKET.hayal)} · ${kapsam.dagilim.dengeli} ${kucuk(DILIM_ETIKET.dengeli)} · ${kapsam.dagilim.garanti} ${kucuk(DILIM_ETIKET.garanti)} olarak kuruldu. Daha fazla seçenek için alan veya il filtreni genişletebilirsin.`,
);
}
const iller = new Set<string>();
for (const t of rapor.tercihler) {
const il = rapor.programlar[t.programId]?.il;
if (il) iller.add(il);
}
if (iller.size === 1) {
const [tekIl] = iller;
uyarilar.push(
`Listenin ${rapor.tercihler.length} tercihi de tek şehirde (${trBaslikDuzeni(tekIl)}). Boşta kalma riskini azaltmak için birkaç farklı şehir eklemeyi düşünebilirsin.`,
);
}
return uyarilar;
}

View File

@@ -90,6 +90,7 @@ function paramsBirlestir(
iller: secimler.iller,
universiteTipi: secimler.universiteTipi,
oncelikler: secimler.oncelikler,
ozelDurum: secimler.ozelDurum,
};
}

View File

@@ -17,6 +17,7 @@ import {
oncelikNormalize,
type TercihProfili,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import { OZEL_DURUM_ETIKET } from "@/lib/ozel-durum";
import type { RaporParams } from "./types/rapor";
export type FarkAlani =
@@ -25,7 +26,8 @@ export type FarkAlani =
| "kategoriler"
| "iller"
| "universiteTipi"
| "oncelikler";
| "oncelikler"
| "ozelDurum";
export const FARK_ETIKET: Record<FarkAlani, string> = {
sira: "başarı sıralaman",
@@ -34,8 +36,17 @@ export const FARK_ETIKET: Record<FarkAlani, string> = {
iller: "şehir tercihlerin",
universiteTipi: "devlet/vakıf tercihin",
oncelikler: "önceliklerin",
ozelDurum: "özel kontenjan durumun",
};
/** Kalkan çipleri (undefined) atarak normalize eder; eski kayıt yeni çip setiyle kıyaslanır. */
function oncelikleriNormalize(
degerler: readonly string[] | undefined,
): string[] | undefined {
if (!degerler) return undefined;
return degerler.map(oncelikNormalize).filter((x): x is string => typeof x === "string");
}
// Sıra bağımsız küme kıyası: sihirbaz seçimleri tekrarsız olarak doğrulanır
// (bkz. sihirbazDogrula), aynı seçimlerin farklı sırayla kaydedilmesi
// "değişiklik" değildir.
@@ -70,9 +81,11 @@ export function tercihFarki(
) {
farklar.push("universiteTipi");
}
if (kumeFarkli(params.oncelikler?.map(oncelikNormalize), s.oncelikler)) {
if (kumeFarkli(oncelikleriNormalize(params.oncelikler), s.oncelikler)) {
farklar.push("oncelikler");
}
// Eski kayıtta alan yoksa (undefined) fark sayılmaz — bkz. dosya başı ilkesi
if (kumeFarkli(params.ozelDurum, s.ozelDurum)) farklar.push("ozelDurum");
return farklar;
}
@@ -110,8 +123,17 @@ export function farkSatirlari(
yeni: UNIVERSITE_TIPI_ETIKET[s.universiteTipi],
};
case "oncelikler":
return { ...ortak, eski: liste(params.oncelikler?.map(oncelikNormalize)),
yeni: liste(s.oncelikler), };
return {
...ortak,
eski: liste(oncelikleriNormalize(params.oncelikler)),
yeni: liste(s.oncelikler),
};
case "ozelDurum":
return {
...ortak,
eski: liste(params.ozelDurum?.map((d) => OZEL_DURUM_ETIKET[d])),
yeni: liste(s.ozelDurum.map((d) => OZEL_DURUM_ETIKET[d])),
};
}
});
}

View File

@@ -3,6 +3,7 @@
// ve bu tiplere karşı derlenir; alan ekleme/çıkarma burada yapılır.
import type { DilimKey, PuanTuruKey, UniturGrubu } from "@/types/yokatlas";
import type { OzelDurum } from "@/lib/ozel-durum";
export type RaporTercih = {
programId: string;
@@ -11,6 +12,12 @@ export type RaporTercih = {
gerekce: string;
riskNotu: string;
trendOzeti: string;
/**
* Koddan hesaplanan "öne çıkan" veriler (lib/uyum-puani.ts): adayın
* önceliklerine / özel durumuna / profiline uyan ölçülebilir yanlar, sayı +
* kaynakla. Eski kayıtlarda ve ölçütsüz listelerde yok.
*/
oneCikan?: string[];
};
/**
@@ -69,6 +76,8 @@ export interface RaporParams {
iller?: string[];
universiteTipi?: UniturGrubu | "farketmez";
oncelikler?: string[];
/** Özel kontenjan durumu (6 Eki 2026; eski kayıtlarda yok) */
ozelDurum?: OzelDurum[];
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
il?: string;
notlar?: string;

View File

@@ -1,3 +1,5 @@
import { cn } from "@/lib/utils";
type RehberKapakProps = {
baslik: string;
className?: string;
@@ -71,7 +73,10 @@ export function RehberKapak({
return (
<div
className={`relative isolate aspect-1200/630 w-full overflow-hidden rounded-3xl border border-[#2563eb]/15 bg-white font-bricolage text-[#2563eb] ${className}`}
className={cn(
"relative isolate aspect-1200/630 w-full overflow-hidden rounded-3xl border border-[#2563eb]/15 bg-white font-bricolage text-[#2563eb]",
className,
)}
role="img"
aria-label={`${baslik} yazı kapağı`}
>

View File

@@ -22,33 +22,42 @@ import { olay } from "@/lib/analitik";
import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler";
import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum";
export function Cip({
secili,
onClick,
children,
kucuk = false,
ton = "notr",
className = "",
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
kucuk?: boolean;
/**
* bilgi: özel kontenjan durumu çipi (DESIGN.md §4.21) — pasif hâli bilgi
* mavisi tonunda (§2 `primary` rolü), seçili hâli nötr çiple aynı.
*/
ton?: "notr" | "bilgi";
className?: string;
}) {
// Şişko ve sade (DESIGN.md §1.1): çip 44 px dokunma hedefi; il
// çipleri (kucuk) çok sayıda olduğu için 40 px. Hover §2.1 tek turuncu.
const boyut = kucuk ? "h-10 px-3.5 text-sm" : "h-11 px-4 text-sm";
const temel = `inline-flex cursor-pointer items-center rounded-full border font-semibold transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] ${boyut} ${className}`;
const pasif =
ton === "bilgi"
? "border-primary/30 bg-primary/10 text-primary hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"
: "border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500";
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili
? `${temel} border-primary bg-primary text-white`
: `${temel} border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500`
secili ? `${temel} border-primary bg-primary text-white` : `${temel} ${pasif}`
}
>
{children}
@@ -91,6 +100,10 @@ export function SihirbazAdimlar({
const [oncelikler, setOncelikler] = useState<string[]>(
baslangicSecimler?.oncelikler ?? [],
);
// Özel kontenjan durumu: isteğe bağlı, havuzu daraltmaz (lib/ozel-durum.ts)
const [ozelDurum, setOzelDurum] = useState<OzelDurum[]>(
baslangicSecimler?.ozelDurum ?? [],
);
// Kademeli facet'ler: kategoriler daima ilk yükten gelir; il ve üniversite
// tipi sayıları adıma girerken o anki seçimlerle tazelenir. Fetch başarısız
@@ -533,8 +546,42 @@ export function SihirbazAdimlar({
))}
</div>
<p className="mt-2 text-sm text-slate-500">
Öncelikler yapay zekânın gerekçelerine ve program seçimine yön
verir; listeyi daraltmaz.
Listeyi daraltmaz; programların sırasını ve gerekçelerini
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
kadro YÖK Atlas).
</p>
</div>
{/* Özel kontenjan durumu (DESIGN.md §4.21): düz ayraç, bilgi tonlu
çipler. Cinsiyet ya da sağlık sorulmaz; "34 yaş üstü kadın" ÖSYM
kontenjan hakkının beyanıdır. Değer analitiğe gitmez. */}
<div className="border-t border-slate-100 pt-4">
<p className="mb-2 text-sm font-semibold text-slate-700">
Özel kontenjan durumun{" "}
<span className="font-normal text-slate-500">(isteğe bağlı)</span>
</p>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{OZEL_DURUMLAR.map((d) => (
<Cip
key={d}
ton="bilgi"
className="h-auto min-h-11 justify-center px-3 py-1.5 text-center leading-tight sm:h-11 sm:px-4 sm:py-0"
secili={ozelDurum.includes(d)}
onClick={() =>
setOzelDurum(
ozelDurum.includes(d)
? ozelDurum.filter((x) => x !== d)
: OZEL_DURUMLAR.filter((x) => x === d || ozelDurum.includes(x)),
)
}
>
{OZEL_DURUM_ETIKET[d]}
</Cip>
))}
</div>
<p className="mt-2 text-sm text-slate-500">
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
kontenjanı olan programlar listende öne çıkar ve danışman bunu
bilir. Cinsiyet ya da sağlık bilgisi sorulmaz.
</p>
</div>
</div>
@@ -571,7 +618,8 @@ export function SihirbazAdimlar({
uni_tipi: universiteTipi,
oncelik_sayisi: oncelikler.length,
});
onTamamla({ kategoriler, iller, universiteTipi, oncelikler });
// ozelDurum olaya YAZILMAZ: profil değeri analitiğe gitmez (gizlilik)
onTamamla({ kategoriler, iller, universiteTipi, oncelikler, ozelDurum });
}}
disabled={kategoriler.length === 0}
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"

View File

@@ -2,21 +2,33 @@
// hem client bileşenleri hem server action'ları/AI motoru buradan okur.
// localStorage'a dokunan profil yardımcıları sihirbaz-profil.ts'te yaşar.
// Kategoriler/iller sıralamaya göre dinamik üretilir (bkz. rankWindowFacets);
// öncelikler statik kalır (veriye bağlı değil, tercih niyeti).
// öncelikler statik kalır ama her biri bir veriye bağlıdır (lib/uyum-puani.ts).
import { KATEGORILER } from "@/lib/kategoriler";
import { ozelDurumDogrula, ozelDurumOzeti, type OzelDurum } from "@/lib/ozel-durum";
import type { UniturGrubu } from "@/types/yokatlas";
/**
* "Senin için önemli olan" çipleri — her biri uyum puanında bir veriye bağlı
* (6 Eki 2026 kararı): İş bulma → TÜİK kayıtlı istihdam; Yüksek maaş → TÜİK
* kazanç grubu; Akademik kariyer → program kadrosu + URAP; Prestij → URAP;
* Kolay yerleşme → liste iskeleti (güvenli dilim payı). "Sevdiğim işi yapmak"
* ve "Yurtdışı imkânı" veri bulunana kadar kaldırıldı (BACKLOG); eski
* kayıtlardaki değerleri `oncelikNormalize` düşürür.
*/
export const ONCELIKLER = [
"İş bulma kolaylığı",
"Yüksek maaş potansiyeli",
"Sevdiğim işi yapmak",
"Akademik kariyer",
"Yurtdışı imkânı",
"Prestijli üniversite",
"Kolay yerleşme",
] as const;
export type Oncelik = (typeof ONCELIKLER)[number];
/** Kaldırılan çipler: eski kayıtlarda görülürse sessizce düşer (fark sayılmaz). */
const KALKAN_ONCELIKLER: readonly string[] = ["Sevdiğim işi yapmak", "Yurtdışı imkânı"];
export const UNIVERSITE_TIPI_ETIKET: Record<UniturGrubu | "farketmez", string> = {
devlet: "Devlet",
vakif: "Vakıf (burslu olursa)",
@@ -56,6 +68,11 @@ export interface SihirbazSecimleri {
iller: string[];
universiteTipi: UniversiteTipi;
oncelikler: string[];
/**
* Özel kontenjan durumu (isteğe bağlı; lib/ozel-durum.ts). Havuzu
* daraltmaz, o kontenjanı olan programları öne alır. Eski kayıtlarda yok.
*/
ozelDurum: OzelDurum[];
}
export interface TercihProfili {
@@ -121,16 +138,22 @@ export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null {
]
: [];
return { kategoriler, iller, universiteTipi, oncelikler };
const ozelDurum = ozelDurumDogrula(p.ozelDurum);
return { kategoriler, iller, universiteTipi, oncelikler, ozelDurum };
}
/**
* Eski çip adı "İş garantisi" → "İş bulma kolaylığı". Kayıtlı profil ve eski
* rapor parametreleri eski adı taşıyabilir; karşılaştırma/gösterim öncesi
* yeni ada eşlenir ki ad değişikliği "tercihlerin değişmiş" diye okunmasın.
* Eski çip adı "İş garantisi" → "İş bulma kolaylığı"; kaldırılan çipler
* ("Sevdiğim işi yapmak", "Yurtdışı imkânı") → undefined. Kayıtlı profil ve
* eski rapor parametreleri eski adı taşıyabilir; karşılaştırma/gösterim
* öncesi normalize edilir ki ad değişikliği ya da kalkan çip "tercihlerin
* değişmiş" diye okunmasın (tercih-farki.ts).
*/
export function oncelikNormalize<T>(x: T): T | string {
return x === "İş garantisi" ? "İş bulma kolaylığı" : x;
export function oncelikNormalize<T>(x: T): T | string | undefined {
if (x === "İş garantisi") return "İş bulma kolaylığı";
if (typeof x === "string" && KALKAN_ONCELIKLER.includes(x)) return undefined;
return x;
}
/** localStorage'taki ortak sıra + sihirbaz profilini güvenle doğrular. */
@@ -196,5 +219,8 @@ export function secimOzeti(secimler: SihirbazSecimleri): string {
if (secimler.oncelikler.length > 0) {
satirlar.push(`- Öncelikleri: ${secimler.oncelikler.join(", ")}`);
}
if (secimler.ozelDurum.length > 0) {
satirlar.push(`- Özel kontenjan durumu: ${ozelDurumOzeti(secimler.ozelDurum)}`);
}
return satirlar.join("\n");
}