Enhance layout and functionality by integrating TercihProfiliKapisiLazy component into layout.tsx, improving user experience with a new section in the RootLayout. Update BolumlerPage and ListemPage for better structure and readability, including adjustments to headings and content organization. Refactor UniversiteIcerik to utilize UniversiteProgramTablosu for displaying program data, and streamline UniversiteKonumHaritasi for improved rendering. Update ManuelListe components for clearer user instructions and enhanced interaction. Overall, these changes aim to improve visual clarity and user engagement across the application.
All checks were successful
Deploy / deploy (push) Successful in 7m25s

This commit is contained in:
bilalgursen
2026-08-01 17:58:38 +03:00
parent 8cf0c3267d
commit 55eb32b22a
21 changed files with 2137 additions and 709 deletions

View File

@@ -78,46 +78,46 @@ export default function BolumlerPage() {
return (
<>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Bölümler", path: "/bolumler" },
])}
/>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Bölümler", path: "/bolumler" },
])}
/>
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
</h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu
görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026
tercihleri için rehber niteliğindedir.
</p>
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
</h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu
görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026
tercihleri için rehber niteliğindedir.
</p>
<PagePixelDivider seed={29} className="mt-8" />
<PagePixelDivider seed={29} className="mt-8" />
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Lisans Bölümleri (4 Yıllık)
</h2>
<KategoriGruplari bolumler={lisans} seviye="lisans" />
</section>
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Lisans Bölümleri (4 Yıllık)
</h2>
<KategoriGruplari bolumler={lisans} seviye="lisans" />
</section>
<div className="mt-12">
<CtaSiraForm />
</div>
<div className="mt-12">
<CtaSiraForm />
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Önlisans Bölümleri (2 Yıllık TYT)
</h2>
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
</section>
</main>
<SiteFooter />
<section className="mt-12">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Önlisans Bölümleri (2 Yıllık TYT)
</h2>
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
</section>
</main>
<SiteFooter />
</>
);
}

View File

@@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel";
@@ -109,6 +110,8 @@ export default function RootLayout({
</ScrollFlow>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesiLazy />
{/* İlk "+" için sıra + sihirbaz kapısı */}
<TercihProfiliKapisiLazy />
<Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}

View File

@@ -0,0 +1,36 @@
"use client";
import { ListChecks, Sparkles } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli";
export type ListemSekme = "secimlerim" | "ai";
export function ListemSekmeleri({
varsayilan,
aiIcerik,
}: {
varsayilan: ListemSekme;
aiIcerik: React.ReactNode;
}) {
return (
<Tabs defaultValue={varsayilan} className="mt-2 gap-6">
<TabsList variant="line" className="h-auto w-full justify-start gap-1">
<TabsTrigger value="secimlerim" className="cursor-pointer px-3 py-2.5">
<ListChecks className="size-4" aria-hidden />
Seçtiklerim
</TabsTrigger>
<TabsTrigger value="ai" className="cursor-pointer px-3 py-2.5">
<Sparkles className="size-4" aria-hidden />
AI Listem
</TabsTrigger>
</TabsList>
<TabsContent value="secimlerim" className="mt-0">
<SecimlerimPaneli />
</TabsContent>
<TabsContent value="ai" className="mt-0">
{aiIcerik}
</TabsContent>
</Tabs>
);
}

View File

@@ -29,6 +29,7 @@ import { PagePixelDivider } from "@/components/pixel-decor";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import { ListemSekmeleri, type ListemSekme } from "./listem-sekmeleri";
export const metadata: Metadata = {
title: "Listem",
@@ -42,18 +43,15 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
export default async function ListemPage({
searchParams,
}: {
searchParams: Promise<{ acildi?: string }>;
searchParams: Promise<{ acildi?: string; sekme?: string }>;
}) {
const session = await verifySession("/listem");
// Kullanıcı satırı, ?acildi parametresi ve rapor birbirinden bağımsız —
// seri await zinciri yerine tek turda çöz.
const [user, { acildi }, satir, sohbetGecmisi] = await Promise.all([
const [user, params, satir, sohbetGecmisi] = await Promise.all([
getCurrentUser(),
searchParams,
appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
}),
// Sohbet geçmişi burada gelirse client mount sonrası /api/soru turu atmaz
appDb
.select({
id: schema.chatMessages.id,
@@ -67,41 +65,64 @@ export default async function ListemPage({
]);
if (!user) redirect("/giris?callback=/listem");
// ?acildi=1: ödeme dönüşü — kilidin kalktığı tek seferlik açılış deneyimi
const { acildi, sekme } = params;
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
const raporVar = Boolean(satir?.result);
// Liste yoksa önce sihirbazdan oluşturulmalı
if (!satir?.result) {
const varsayilanSekme: ListemSekme =
sekme === "secimlerim"
? "secimlerim"
: sekme === "ai" || acildi === "1" || raporVar
? "ai"
: "secimlerim";
const aiBos = (
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h2 className="mt-4 font-heading text-2xl font-bold">
Henüz AI listen yok
</h2>
<p className="mt-3 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur;
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
Seçtiklerim sekmesinde ise tablolardan eklediğin programlar durur.
</p>
<PagePixelDivider seed={67} className="mx-auto mt-6" />
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
>
<Link href="/">Sıralamanı gir, AI listeni kur</Link>
</Button>
</section>
);
if (!raporVar) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz bir listen yok
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-3 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur;
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
</p>
<PagePixelDivider seed={67} className="mx-auto mt-6" />
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
>
<Link href="/">Sıralamanı gir, listeni kur</Link>
</Button>
<PagePixelDivider seed={71} className="mt-6" />
<ListemSekmeleri
varsayilan={varsayilanSekme}
aiIcerik={aiBos}
/>
</main>
);
}
// Paketsize giden rapor burada da maskelenir (bkz. rapor-maske)
const rapor = user.hasPaket
? (satir.result as RaporSonuc)
: raporMaskele(satir.result as RaporSonuc);
const params = satir.params as RaporParams;
? (satir!.result as RaporSonuc)
: raporMaskele(satir!.result as RaporSonuc);
const raporParams = satir!.params as RaporParams;
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
const aiIcerik = (
<>
{kilitAcilisi ? (
<div
role="status"
@@ -109,7 +130,7 @@ export default async function ListemPage({
>
<Sparkles className="size-4 shrink-0" aria-hidden />
Paketin aktif 24 tercihinin tamamı, gerekçeler, riskler ve PDF
artık ık. 🎉
artık ık.
</div>
) : null}
<div className="flex flex-wrap items-end justify-between gap-4">
@@ -125,14 +146,16 @@ export default async function ListemPage({
YÖK Atlas verileri ücretsiz
</Badge>
)}
<h1 className="max-w-2xl font-heading text-2xl font-bold tracking-tight sm:text-3xl">
<h2 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
{user.hasPaket
? "Tercih karar merkezin"
? "AI tercih listen"
: "Veriyi ücretsiz incele. Kararı AI ile netleştir."}
</h1>
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{params.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[params.tur]} ·{" "}
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
raporParams.tur}{" "}
·{" "}
{user.hasPaket
? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin."
: "24 programın 20242025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
@@ -165,16 +188,13 @@ export default async function ListemPage({
</div>
) : null}
</div>
<PagePixelDivider seed={71} className="mt-6" />
{/* AI'ın genel değerlendirmesi ve uyarıları: paketliye tam,
paketsize sunucuda kırpılmış önizleme + kilit notu */}
{rapor.genelDegerlendirme ? (
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 font-heading text-base font-bold">
<h3 className="flex items-center gap-2 font-heading text-base font-bold">
<Bot className="size-4 text-primary" aria-hidden />
Danışman özeti
</h2>
</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{rapor.genelDegerlendirme}
</p>
@@ -205,51 +225,51 @@ export default async function ListemPage({
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-w-0 flex-col gap-6">
<section
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
kilitAcilisi ? " kt-unblur" : ""
}`}
>
<RaporListesi
rapor={rapor}
adaySira={params.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
<Lock className="size-4" aria-hidden />
</span>
<div>
<p className="font-heading font-bold text-slate-900">
İlk {ACIK_SATIR} tercihte AI analizini gördün
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
AI yorumu paketle ılır. Program adları ve taban verileri
her zaman ücretsiz.
</p>
<section
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
kilitAcilisi ? " kt-unblur" : ""
}`}
>
<RaporListesi
rapor={rapor}
adaySira={raporParams.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
<Lock className="size-4" aria-hidden />
</span>
<div>
<p className="font-heading font-bold text-slate-900">
İlk {ACIK_SATIR} tercihte AI analizini gördün
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
AI yorumu paketle ılır. Program adları ve taban
verileri her zaman ücretsiz.
</p>
</div>
</div>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">
Analizleri {PAKET_FIYATI} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">
Analizleri {PAKET_FIYATI} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div>
}
/>
</section>
}
/>
</section>
{user.hasPaket ? (
<RevizyonKutusu
kalanHak={Math.max(0, MAX_REVIZYON - (satir.revisionCount ?? 0))}
/>
) : null}
{user.hasPaket ? (
<RevizyonKutusu
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
/>
) : null}
</div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
@@ -260,91 +280,87 @@ export default async function ListemPage({
/>
) : (
<div className="flex flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 p-6">
<div className="flex items-center gap-2 text-primary">
<Sparkles className="size-4" aria-hidden />
<span className="text-xs font-bold uppercase tracking-wide">
Tercih Dönemi Paketi
</span>
</div>
<h2 className="mt-3 font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sadece veriyi göstermez; hangi tercihin neden mantıklı
olduğunu sıralamana göre ıklar.
</p>
</div>
<div className="p-6">
<ul className="flex flex-col gap-3 text-sm">
{[
"24 tercihin tamamında kişisel risk analizi",
"Her program için kısa, gerekçeli AI yorumu",
"2 liste revizyonu ve paylaşılabilir PDF",
`${URUNLER.paket.credits} AI danışman sorusu`,
].map((ozellik) => (
<li key={ozellik} className="flex items-start gap-2.5">
<Check
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
<span>{ozellik}</span>
</li>
))}
</ul>
<div className="mt-6 flex items-end justify-between">
<div>
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{PAKET_FIYATI} TL
</p>
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 p-6">
<div className="flex items-center gap-2 text-primary">
<Sparkles className="size-4" aria-hidden />
<span className="text-xs font-bold uppercase tracking-wide">
Tercih Dönemi Paketi
</span>
</div>
<Badge variant="outline">Abonelik yok</Badge>
<h3 className="mt-3 font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sadece veriyi göstermez; hangi tercihin neden mantıklı
olduğunu sıralamana göre ıklar.
</p>
</div>
<Button
asChild
size="lg"
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Link href="/paket">
Kişisel analizi
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
<div className="p-6">
<ul className="flex flex-col gap-3 text-sm">
{[
"24 tercihin tamamında kişisel risk analizi",
"Her program için kısa, gerekçeli AI yorumu",
"2 liste revizyonu ve paylaşılabilir PDF",
`${URUNLER.paket.credits} AI danışman sorusu`,
].map((ozellik) => (
<li key={ozellik} className="flex items-start gap-2.5">
<Check
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
<span>{ozellik}</span>
</li>
))}
</ul>
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<ShieldCheck className="size-3.5" aria-hidden />
iyzico güvenli ödeme
</span>
<span className="inline-flex items-center gap-1">
<Bot className="size-3.5" aria-hidden />
Gerçek YÖK verisi
</span>
<div className="mt-6 flex items-end justify-between">
<div>
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{PAKET_FIYATI} TL
</p>
</div>
<Badge variant="outline">Abonelik yok</Badge>
</div>
<Button
asChild
size="lg"
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Link href="/paket">
Kişisel analizi
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<ShieldCheck className="size-3.5" aria-hidden />
iyzico güvenli ödeme
</span>
<span className="inline-flex items-center gap-1">
<Bot className="size-3.5" aria-hidden />
Gerçek YÖK verisi
</span>
</div>
</div>
</div>
</section>
</section>
{/* Deneme kredileri sohbette geçerli: AI'ı satın almadan önce
tattırır (kalan kredi biterse composer paket CTA'sına döner). */}
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
className="h-[480px]"
/>
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
className="h-[480px]"
/>
</div>
)}
</div>
</div>
{/* Mobilde sohbet sağ rayla birlikte en alta düşer; yüzen kısayol
kullanıcıyı 24 satırı kaydırmadan danışmana ulaştırır. */}
<ViewportPortal>
<a
href="#sohbet"
@@ -354,6 +370,19 @@ export default async function ListemPage({
Danışmana sor
</a>
</ViewportPortal>
</>
);
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
</p>
<PagePixelDivider seed={71} className="mt-6" />
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiIcerik} />
</main>
);
}

View File

@@ -10,26 +10,11 @@ import { SiteFooter } from "@/components/site-footer";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { PagePixelDivider } from "@/components/pixel-decor";
import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu";
import type { Program } from "@/lib/db";
const sayi = (n: number) => n.toLocaleString("tr-TR");
const TUR_ETIKET: Record<string, string> = {
SAYISAL: "SAY",
"EŞİT AĞIRLIK": "EA",
SÖZEL: "SÖZ",
DİL: "DİL",
TYT: "TYT",
};
export function uniToplamSayfa(programSayisi: number): number {
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
}
@@ -69,12 +54,16 @@ export function UniversiteIcerik({
(sayfa - 1) * SAYFA_BOYU,
sayfa * SAYFA_BOYU,
);
const gruplar: { fakulte: string; programlar: Program[] }[] = [];
const gruplar: {
fakulte: string;
programlar: (Program & { bolumSlug: string | null })[];
}[] = [];
for (const p of dilim) {
const anahtar = p.fakulte ?? "Diğer";
const satir = { ...p, bolumSlug: bolumSlugFromIsim(p.isim) };
const son = gruplar[gruplar.length - 1];
if (son && son.fakulte === anahtar) son.programlar.push(p);
else gruplar.push({ fakulte: anahtar, programlar: [p] });
if (son && son.fakulte === anahtar) son.programlar.push(satir);
else gruplar.push({ fakulte: anahtar, programlar: [satir] });
}
const ilkSayfa = sayfa === 1;
@@ -178,6 +167,7 @@ export function UniversiteIcerik({
</p>
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
<dl className="contents">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
@@ -216,7 +206,6 @@ export function UniversiteIcerik({
</dd>
</div>
</dl>
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
</div>
</>
) : (
@@ -238,10 +227,7 @@ export function UniversiteIcerik({
<PagePixelDivider seed={57} className="mt-8" />
<div
id="programlar"
className="mt-8"
>
<div id="programlar" className="mt-8">
<h2 className="font-heading text-xl font-bold text-slate-900">
Üniversite programları
</h2>
@@ -250,67 +236,7 @@ export function UniversiteIcerik({
</p>
</div>
{gruplar.map(({ fakulte, programlar }, i) => (
<section key={`${fakulte}-${i}`} className="mt-6">
<h2 className="mb-3 font-heading text-xl font-bold text-slate-900">
{fakulte}
</h2>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Program</TableHead>
<TableHead>Puan Türü</TableHead>
<TableHead className="text-right">2025 Puan</TableHead>
<TableHead className="text-right">2025 Sıralama</TableHead>
<TableHead className="text-right">2024 Sıralama</TableHead>
<TableHead className="text-right">Kontenjan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programlar.map((p) => {
const bolumSlug = bolumSlugFromIsim(p.isim);
return (
<TableRow key={p.id}>
<TableCell className="max-w-72">
{bolumSlug ? (
<Link
href={`/bolum/${bolumSlug}`}
className="font-medium text-slate-900 hover:text-primary hover:underline"
>
{p.isim.trim()}
</Link>
) : (
<span className="font-medium">
{p.isim.trim()}
</span>
)}
</TableCell>
<TableCell>{TUR_ETIKET[p.tur] ?? p.tur}</TableCell>
<TableCell className="text-right tabular-nums">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.sira2025 != null ? sayi(p.sira2025) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.sira2024 != null ? sayi(p.sira2024) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.kontenjan2025 != null
? sayi(p.kontenjan2025)
: "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</section>
))}
<UniversiteProgramTablosu gruplar={gruplar} />
<p className="mt-3 text-xs leading-5 text-slate-500">
&quot;&quot;: o yıl için yerleştirme verisi bulunmuyor (yeni ılan

View File

@@ -49,64 +49,64 @@ export default function UniversitelerPage() {
return (
<>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Üniversiteler", path: "/universiteler" },
])}
/>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Üniversiteler", path: "/universiteler" },
])}
/>
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Üniversitelerin Taban Puanları ve Sıralamaları 2026
</h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler
YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için
rehber niteliğindedir.
</p>
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Üniversitelerin Taban Puanları ve Sıralamaları 2026
</h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler
YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için
rehber niteliğindedir.
</p>
<PagePixelDivider seed={63} className="mt-8" />
<PagePixelDivider seed={63} className="mt-8" />
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Devlet Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(devlet.length)}
</span>
</h2>
<UniLinkleri uniler={devlet} />
</section>
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Vakıf Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(vakif.length)}
</span>
</h2>
<UniLinkleri uniler={vakif} />
</section>
{digerUniler.length > 0 ? (
<section className="mt-10">
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
KKTC ve Yurt Dışı
Devlet Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(digerUniler.length)}
{sayi(devlet.length)}
</span>
</h2>
<UniLinkleri uniler={digerUniler} />
<UniLinkleri uniler={devlet} />
</section>
) : null}
<div className="mt-12">
<CtaSiraForm />
</div>
</main>
<SiteFooter />
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Vakıf Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(vakif.length)}
</span>
</h2>
<UniLinkleri uniler={vakif} />
</section>
{digerUniler.length > 0 ? (
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
KKTC ve Yurt Dışı
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(digerUniler.length)}
</span>
</h2>
<UniLinkleri uniler={digerUniler} />
</section>
) : null}
<div className="mt-12">
<CtaSiraForm />
</div>
</main>
<SiteFooter />
</>
);
}

View File

@@ -3,9 +3,21 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { Program } from "@/lib/db";
import { trBaslikDuzeni } from "@/lib/slug";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -47,13 +59,17 @@ function SiralamaDugmesi({
<span className="flex flex-col -space-y-1" aria-hidden>
<ChevronUp
className={`size-3 ${
aktif && yon === "artan" ? "text-primary" : "text-muted-foreground/40"
aktif && yon === "artan"
? "text-primary"
: "text-muted-foreground/40"
}`}
strokeWidth={aktif && yon === "artan" ? 3 : 2}
/>
<ChevronDown
className={`size-3 ${
aktif && yon === "azalan" ? "text-primary" : "text-muted-foreground/40"
aktif && yon === "azalan"
? "text-primary"
: "text-muted-foreground/40"
}`}
strokeWidth={aktif && yon === "azalan" ? 3 : 2}
/>
@@ -85,11 +101,7 @@ export function BolumProgramListesi({
if (bDeger == null) return -1;
const fark = aDeger - bDeger;
return fark === 0
? a.index - b.index
: yon === "artan"
? fark
: -fark;
return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark;
})
.map(({ program }) => program);
}, [aktifAlan, programlar, yon]);
@@ -104,88 +116,116 @@ export function BolumProgramListesi({
}
return (
<ul className="divide-y divide-border overflow-hidden rounded-2xl border bg-card">
{siraliProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
return (
<li
key={p.id}
className="grid gap-4 px-4 py-5 sm:px-5 lg:grid-cols-[minmax(0,1fr)_8rem_8rem_7rem] lg:items-center"
>
<div className="min-w-0">
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className="font-semibold text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{uniAd}
</Link>
) : (
<p className="font-semibold text-foreground">{uniAd}</p>
)}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{p.il ? <span>{trBaslikDuzeni(p.il)}</span> : null}
{p.il && p.unitur ? <span aria-hidden>·</span> : null}
{p.unitur ? (
<Badge variant="secondary" className="font-normal">
{p.unitur === "DEVLET"
? "Devlet"
: trBaslikDuzeni(p.unitur)}
</Badge>
) : null}
</div>
{varyant ? (
<p className="mt-2 text-sm text-muted-foreground">{varyant}</p>
) : null}
</div>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Üniversite</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
<SiralamaDugmesi
alan="puan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
<SiralamaDugmesi
alan="kontenjan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="text-right">
<SiralamaDugmesi
alan="sira2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{siraliProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
const devlet = p.unitur === "DEVLET";
<dl className="grid grid-cols-3 gap-3 lg:contents">
<div className="lg:text-right">
<dt>
<SiralamaDugmesi
alan="sira2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</dt>
<dd className="mt-1 font-semibold tabular-nums text-foreground">
{p.sira2025 != null ? sayi(p.sira2025) : "—"}
</dd>
</div>
<div className="lg:text-right">
<dt>
<SiralamaDugmesi
alan="puan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</dt>
<dd className="mt-1 font-medium tabular-nums text-foreground">
return (
<TableRow key={p.id}>
<TableCell className="max-w-0 w-full">
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{uniAd}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{uniAd}
</span>
)}
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
</span>
) : null}
<span className="truncate">
{p.il ? trBaslikDuzeni(p.il) : null}
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</dd>
</div>
<div className="lg:text-right">
<dt>
<SiralamaDugmesi
alan="kontenjan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={p} />
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
</dt>
<dd className="mt-1 font-medium tabular-nums text-foreground">
{p.kontenjan2025 != null ? sayi(p.kontenjan2025) : "—"}
</dd>
</div>
</dl>
</li>
);
})}
</ul>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -178,8 +178,8 @@ function CekmeceSheet() {
/>
</div>
<p className="mt-2 text-xs text-slate-500">
Sıralamanla ulaşabildiğin programlardan kendi listen tutamaktan
sürükleyip sırala, sağa kaydırıp sil.
Bölüm, üniversite veya sonuç tablolarından eklediğin programlar
tutamaktan sürükleyip sırala, sağa kaydırıp sil.
</p>
</motion.div>
@@ -194,8 +194,9 @@ function CekmeceSheet() {
Henüz program seçmedin
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; beğendiklerin burada birikir.
Bölüm, üniversite veya sonuç sayfalarındaki tablolardan{" "}
<span aria-hidden></span> ile program ekle; beğendiklerin burada
birikir.
</p>
</motion.div>
) : (
@@ -222,15 +223,23 @@ function CekmeceSheet() {
</Reorder.Group>
)}
{/* Alt bant: AI danışman köprüsü */}
{/* Alt bant: karar merkezi köprüsü */}
<motion.div
layout="position"
className="border-t border-slate-100 px-5 py-4"
className="flex flex-col gap-2 border-t border-slate-100 px-5 py-4"
>
<Link
href="/listem"
href="/listem?sekme=secimlerim"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<ListChecks className="size-4" aria-hidden />
Seçtiklerimi ve uyum notlarını
</Link>
<Link
href="/listem?sekme=ai"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors duration-200 hover:text-primary"
>
<Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı

View File

@@ -0,0 +1,190 @@
"use client";
// Global "+" kapısı: profil yokken bekleyen programı tutar, modalı açar.
// Layout'ta bir kez monte edilen TercihProfiliKapisi bu store'a abone olur.
import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db";
import {
tercihProfiliOku,
tercihProfiliTamamlandiMi,
type TercihProfili,
} from "@/lib/sihirbaz";
import {
ekle,
listedeMi,
MANUEL_LISTE_MAX,
programdanManuelTercih,
riskleriYenile,
useManuelListe,
type ManuelKaynak,
} from "./store";
export type BekleyenProgram = {
program: Pick<
Program,
| "id"
| "isim"
| "universite"
| "il"
| "unitur"
| "tur"
| "sira2025"
| "sira2024"
>;
kaynak: ManuelKaynak;
adaySira?: number;
adayTur?: string;
};
type KapıDurumu = {
acik: boolean;
duzenle: boolean;
bekleyen: BekleyenProgram | null;
profil: TercihProfili | null;
profilYuklendi: boolean;
};
let durum: KapıDurumu = {
acik: false,
duzenle: false,
bekleyen: null,
profil: null,
profilYuklendi: false,
};
const dinleyiciler = new Set<() => void>();
function yayinla() {
for (const cb of dinleyiciler) cb();
}
function profiliTazele() {
durum = {
...durum,
profil: tercihProfiliOku(),
profilYuklendi: true,
};
}
function yukle() {
if (durum.profilYuklendi || typeof window === "undefined") return;
profiliTazele();
window.addEventListener("storage", (e) => {
if (e.key !== "kolaytercih.sihirbaz") return;
profiliTazele();
yayinla();
});
}
function abone(cb: () => void) {
dinleyiciler.add(cb);
if (!durum.profilYuklendi && typeof window !== "undefined") {
yukle();
queueMicrotask(yayinla);
}
return () => {
dinleyiciler.delete(cb);
};
}
const SSR_DURUM: KapıDurumu = {
acik: false,
duzenle: false,
bekleyen: null,
profil: null,
profilYuklendi: false,
};
export function useProfilKapisi(): KapıDurumu {
return useSyncExternalStore(abone, () => durum, () => SSR_DURUM);
}
export function useTercihProfili(): TercihProfili | null {
return useSyncExternalStore(
abone,
() => {
if (!durum.profilYuklendi) yukle();
return durum.profil;
},
() => null,
);
}
export function profilKapisiniKapat() {
durum = { ...durum, acik: false, duzenle: false, bekleyen: null };
yayinla();
}
export function profilDuzenlemeyiAc() {
if (!durum.profilYuklendi) yukle();
durum = { ...durum, acik: true, duzenle: true, bekleyen: null };
yayinla();
}
export function programEkleIste(opts: {
program: BekleyenProgram["program"];
kaynak?: ManuelKaynak;
adaySira?: number;
adayTur?: string;
}): {
eklendi: boolean;
zatenVar: boolean;
doldu: boolean;
kapiAcildi: boolean;
} {
const { program, kaynak = "sonuc", adaySira, adayTur } = opts;
if (listedeMi(program.id)) {
return { eklendi: false, zatenVar: true, doldu: false, kapiAcildi: false };
}
if (!tercihProfiliTamamlandiMi()) {
durum = {
...durum,
acik: true,
duzenle: false,
bekleyen: { program, kaynak, adaySira, adayTur },
};
yayinla();
return { eklendi: false, zatenVar: false, doldu: false, kapiAcildi: true };
}
if (!durum.profilYuklendi) {
profiliTazele();
}
const profil = durum.profil ?? tercihProfiliOku();
const tercih = programdanManuelTercih(
program,
adaySira ?? profil?.sira ?? null,
kaynak,
);
const ok = ekle(tercih);
if (!ok) {
return { eklendi: false, zatenVar: false, doldu: true, kapiAcildi: false };
}
return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false };
}
export function profilTamamlandi(profil: TercihProfili) {
riskleriYenile(profil);
const bekleyen = durum.bekleyen;
if (bekleyen) {
ekle(programdanManuelTercih(bekleyen.program, profil.sira, bekleyen.kaynak));
}
durum = {
...durum,
acik: false,
duzenle: false,
bekleyen: null,
profil,
profilYuklendi: true,
};
yayinla();
}
export function useListeDolu(): boolean {
const liste = useManuelListe();
return liste.length >= MANUEL_LISTE_MAX;
}

View File

@@ -0,0 +1,89 @@
"use client";
import { Check, Plus } from "lucide-react";
import { toast } from "sonner";
import type { Program } from "@/lib/db";
import {
listedeMi,
MANUEL_LISTE_MAX,
useManuelListe,
type ManuelKaynak,
} from "./store";
import { programEkleIste } from "./profil-kapisi-store";
type ProgramOzet = Pick<
Program,
| "id"
| "isim"
| "universite"
| "il"
| "unitur"
| "tur"
| "sira2025"
| "sira2024"
>;
export function ProgramEkleButonu({
program,
kaynak = "sonuc",
adaySira,
adayTur,
className = "",
}: {
program: ProgramOzet;
kaynak?: ManuelKaynak;
adaySira?: number;
adayTur?: string;
className?: string;
}) {
const liste = useManuelListe();
const listede = liste.some((t) => t.id === program.id) || listedeMi(program.id);
const doldu = liste.length >= MANUEL_LISTE_MAX;
function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur });
if (sonuc.eklendi) {
toast.success("Program listene eklendi. Sıralamana ve tercihlerine göre değerlendirdik.");
} else if (sonuc.doldu) {
toast.info(`Listen ${MANUEL_LISTE_MAX} tercihe ulaştı.`);
} else if (sonuc.zatenVar) {
toast.info("Bu program zaten seçtiklerinde.");
}
// kapiAcildi: modal açılır, toast yok
}
return (
<button
type="button"
disabled={listede || doldu}
onClick={handleClick}
title={
listede
? "Zaten seçtiklerinde"
: doldu
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Seçtiklerime ekle"
}
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
listede
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
} ${className}`}
>
{listede ? (
<Check className="size-4" aria-hidden />
) : (
<Plus className="size-4" aria-hidden />
)}
<span className="sr-only">
{listede
? `${program.isim} listende`
: doldu
? `Listen ${MANUEL_LISTE_MAX} tercihe ulaştı`
: `${program.isim} seçtiklerine ekle`}
</span>
</button>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import Link from "next/link";
import {
AlertTriangle,
CheckCircle2,
ListChecks,
PencilLine,
Sparkles,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import {
tercihDegerlendir,
tercihListesiOzeti,
} from "@/lib/tercih-degerlendirme";
import {
cikar,
MANUEL_LISTE_MAX,
useManuelListe,
type ManuelTercih,
} from "./store";
import {
profilDuzenlemeyiAc,
useTercihProfili,
} from "./profil-kapisi-store";
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
"az-riskli": { nokta: "bg-amber-500", kenar: "border-l-amber-500" },
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500" },
};
export function SecimlerimPaneli() {
const liste = useManuelListe();
const profil = useTercihProfili();
const ozet = profil
? tercihListesiOzeti(
liste.map((t) => ({
isim: t.isim,
il: t.il,
unitur: t.unitur,
efektifSira: t.efektifSira,
})),
profil,
)
: null;
if (liste.length === 0) {
return (
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center">
<ListChecks className="mx-auto size-10 text-slate-300" aria-hidden />
<h2 className="mt-4 font-heading text-xl font-bold">
Henüz program seçmedin
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Bölüm, üniversite veya sonuç sayfalarındaki tablolardan{" "}
<span aria-hidden></span> ile program ekle. Tercihlerine göre
risk ve uyum notlarını burada göreceksin.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/bolumler">Bölümlere göz at</Link>
</Button>
<Button asChild className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600">
<Link href="/">Sıralamanı gir</Link>
</Button>
</div>
</section>
);
}
return (
<section className="flex flex-col gap-6">
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 font-heading text-xl font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Seçtiklerim
<span className="text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<p className="mt-1 text-sm text-slate-600">
{profil
? `${profil.sira.toLocaleString("tr-TR")}. sıra · tercihlerine göre değerlendirme`
: "Tercih profilin oluşunca uyum notları görünür."}
</p>
</div>
{profil ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer"
>
<PencilLine className="size-4" aria-hidden />
Tercihlerimi düzenle
</Button>
) : null}
</div>
<div
className="mt-4 h-1.5 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{
width: `${Math.round((liste.length / MANUEL_LISTE_MAX) * 100)}%`,
}}
/>
</div>
{ozet ? (
<dl className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Istatistik etiket="Hayal" deger={ozet.hayal} />
<Istatistik etiket="Dengeli" deger={ozet.dengeli} />
<Istatistik etiket="Daha güvenli" deger={ozet.dahaGuvenli} />
<Istatistik etiket="Dikkat" deger={ozet.dikkat} />
</dl>
) : null}
{ozet ? (
<p className="mt-4 flex items-start gap-2 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2.5 text-sm text-slate-700">
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
{ozet.oneri}
{ozet.gucluUyum > 0 ? (
<span className="sr-only">
{ozet.gucluUyum} program güçlü uyum gösteriyor.
</span>
) : null}
</p>
) : null}
</div>
<ol className="flex flex-col gap-3">
{liste.map((t, i) => (
<SecimSatiri key={t.id} tercih={t} sira={i + 1} />
))}
</ol>
</section>
);
}
function Istatistik({ etiket, deger }: { etiket: string; deger: number }) {
return (
<div className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500">
{etiket}
</dt>
<dd className="mt-0.5 font-heading text-xl font-bold tabular-nums text-slate-900">
{deger}
</dd>
</div>
);
}
function SecimSatiri({
tercih,
sira,
}: {
tercih: ManuelTercih;
sira: number;
}) {
const profil = useTercihProfili();
const degerlendirme = profil
? tercihDegerlendir(
{
isim: tercih.isim,
il: tercih.il,
unitur: tercih.unitur,
efektifSira: tercih.efektifSira,
},
profil,
)
: null;
const risk = degerlendirme?.risk ?? tercih.risk;
const stil = risk ? RISK_STIL[risk] : null;
return (
<li
className={`rounded-2xl border border-l-4 border-slate-200 bg-white p-4 ${
stil?.kenar ?? "border-l-slate-300"
}`}
>
<div className="flex items-start gap-3">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold tabular-nums">
{sira}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-900">
{tercih.isim}
</p>
<p className="truncate text-xs text-slate-500">
{tercih.universite}
{tercih.il
? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}`
: ""}
{tercih.efektifSira != null
? ` · ~${tercih.efektifSira.toLocaleString("tr-TR")}.`
: ""}
</p>
</div>
<button
type="button"
onClick={() => cikar(tercih.id)}
className="shrink-0 cursor-pointer rounded-full p-2 text-slate-300 transition-colors hover:bg-slate-100 hover:text-red-500"
>
<X className="size-4" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span>
</button>
</div>
{degerlendirme ? (
<div className="mt-3 flex flex-col gap-2">
<p className="flex items-center gap-2 text-xs font-medium text-slate-700">
{risk ? (
<span
className={`size-2 rounded-full ${RISK_STIL[risk].nokta}`}
aria-hidden
/>
) : null}
{degerlendirme.riskEtiketi}
{risk ? (
<span className="font-normal text-slate-500">
({RISK_ETIKET[risk]})
</span>
) : null}
</p>
<p className="text-sm text-slate-600">{degerlendirme.riskMetni}</p>
{degerlendirme.olumlu.slice(0, 2).map((m) => (
<p
key={m}
className="flex items-start gap-1.5 text-sm text-emerald-800"
>
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{m}
</p>
))}
{degerlendirme.celiskiler.slice(0, 2).map((m) => (
<p
key={m}
className="flex items-start gap-1.5 text-sm text-amber-900"
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{m}
</p>
))}
</div>
) : (
<p className="mt-2 text-sm text-slate-500">
Tercih profilin tamamlanınca bu programı değerlendireceğiz.
</p>
)}
</div>
</div>
</li>
);
}

View File

@@ -6,20 +6,30 @@
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
import { useSyncExternalStore } from "react";
import type { RiskSeviyesi } from "@/lib/risk";
import type { Program } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import {
tercihProfiliOku,
type TercihProfili,
} from "@/lib/sihirbaz";
export const MANUEL_LISTE_MAX = 24;
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
export type ManuelTercih = {
/** yokatlas program id'si */
id: string;
isim: string;
universite: string;
il: string | null;
unitur: string | null;
tur: string | null;
efektifSira: number | null;
/** Ekleme anındaki aday sıralamasına göre risk (renklendirme için) */
/** Ekleme / profil anındaki aday sıralamasına göre risk */
risk: RiskSeviyesi | null;
kaynak?: ManuelKaynak;
};
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
@@ -43,15 +53,41 @@ function kaydet() {
function dogrula(ham: unknown): ManuelTercih[] {
if (!Array.isArray(ham)) return [];
return ham
.filter(
(t): t is ManuelTercih =>
typeof t === "object" &&
t !== null &&
typeof (t as ManuelTercih).id === "string" &&
typeof (t as ManuelTercih).isim === "string",
)
.slice(0, MANUEL_LISTE_MAX);
const sonuc: ManuelTercih[] = [];
for (const t of ham) {
if (typeof t !== "object" || t === null) continue;
const kayit = t as Record<string, unknown>;
if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
const risk: RiskSeviyesi | null =
kayit.risk === "guvenli" ||
kayit.risk === "az-riskli" ||
kayit.risk === "riskli"
? kayit.risk
: null;
const kaynak: ManuelKaynak | undefined =
kayit.kaynak === "sonuc" ||
kayit.kaynak === "bolum" ||
kayit.kaynak === "universite"
? kayit.kaynak
: undefined;
sonuc.push({
id: kayit.id,
isim: kayit.isim,
universite: typeof kayit.universite === "string" ? kayit.universite : "",
il: typeof kayit.il === "string" ? kayit.il : null,
unitur: typeof kayit.unitur === "string" ? kayit.unitur : null,
tur: typeof kayit.tur === "string" ? kayit.tur : null,
efektifSira:
typeof kayit.efektifSira === "number" &&
Number.isFinite(kayit.efektifSira)
? kayit.efektifSira
: null,
risk,
kaynak,
});
if (sonuc.length >= MANUEL_LISTE_MAX) break;
}
return sonuc;
}
function yukle() {
@@ -103,11 +139,71 @@ export function listedeMi(id: string): boolean {
return liste.some((t) => t.id === id);
}
export function efektifSiraAl(
p: Pick<Program, "sira2025" | "sira2024">,
): number | null {
return p.sira2025 ?? p.sira2024 ?? null;
}
/** Program satırını manuel liste kaydına çevirir. */
export function programdanManuelTercih(
p: Pick<
Program,
| "id"
| "isim"
| "universite"
| "il"
| "unitur"
| "tur"
| "sira2025"
| "sira2024"
>,
adaySira?: number | null,
kaynak: ManuelKaynak = "sonuc",
): ManuelTercih {
const taban = efektifSiraAl(p);
return {
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur ?? null,
tur: p.tur,
efektifSira: taban,
risk:
adaySira != null && Number.isFinite(adaySira)
? riskHesapla(taban, adaySira)
: null,
kaynak,
};
}
/** Profil sırasına göre listedeki riskleri yeniden hesaplar (program silmez). */
export function riskleriYenile(profil?: TercihProfili | null) {
yukle();
const p = profil ?? tercihProfiliOku();
if (!p) return;
liste = liste.map((t) => ({
...t,
risk: riskHesapla(t.efektifSira, p.sira),
}));
kaydet();
yayinla();
}
/** Listeye ekler; doluysa veya zaten varsa false döner. */
export function ekle(tercih: ManuelTercih): boolean {
yukle();
if (liste.length >= MANUEL_LISTE_MAX || listedeMi(tercih.id)) return false;
liste = [...liste, tercih];
const profil = tercihProfiliOku();
const kayit: ManuelTercih = {
...tercih,
risk:
profil != null
? riskHesapla(tercih.efektifSira, profil.sira)
: tercih.risk,
};
liste = [...liste, kayit];
kaydet();
yayinla();
return true;

View File

@@ -0,0 +1,8 @@
"use client";
import dynamic from "next/dynamic";
export const TercihProfiliKapisiLazy = dynamic(
() => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi),
{ ssr: false },
);

View File

@@ -0,0 +1,319 @@
"use client";
import { useState } from "react";
import { ArrowRight, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import {
SihirbazAdimlarLazy,
preloadSihirbazAdimlar,
} from "@/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/lib/db";
import {
tercihProfiliYaz,
type SihirbazSecimleri,
type TercihProfili,
} from "@/lib/sihirbaz";
import {
profilKapisiniKapat,
profilTamamlandi,
useProfilKapisi,
type BekleyenProgram,
} from "./profil-kapisi-store";
const PUAN_TURLERI_SECENEK = [
{ deger: "say", etiket: "Sayısal" },
{ deger: "ea", etiket: "Eşit Ağırlık" },
{ deger: "soz", etiket: "Sözel" },
{ deger: "dil", etiket: "Dil" },
{ deger: "tyt", etiket: "TYT" },
] as const;
type Adim = "giris" | "sira" | "sihirbaz";
export function TercihProfiliKapisi() {
const { acik, duzenle, bekleyen, profil } = useProfilKapisi();
const oturumAnahtari = acik
? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}`
: "kapali";
return (
<Dialog
open={acik}
onOpenChange={(v) => {
if (!v) profilKapisiniKapat();
}}
>
<DialogContent
data-tercih-profili-kapisi
className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"
>
<DialogTitle className="sr-only">
{duzenle ? "Tercihlerini düzenle" : "Tercih profili sihirbazı"}
</DialogTitle>
<DialogDescription className="sr-only">
Sıralamanı ve tercihlerini belirle; eklediğin programları buna göre
değerlendirelim.
</DialogDescription>
{acik ? (
<KapıIcerik
key={oturumAnahtari}
duzenle={duzenle}
bekleyen={bekleyen}
profil={profil}
/>
) : null}
</DialogContent>
</Dialog>
);
}
function KapıIcerik({
duzenle,
bekleyen,
profil,
}: {
duzenle: boolean;
bekleyen: BekleyenProgram | null;
profil: TercihProfili | null;
}) {
const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
const [adim, setAdim] = useState<Adim>("giris");
const [siralama, setSiralama] = useState(
bilinenSira != null ? bilinenSira.toLocaleString("tr-TR") : "",
);
const [tur, setTur] = useState(bilinenTur);
const [sira, setSira] = useState<number | null>(bilinenSira);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
const [hata, setHata] = useState<string | null>(null);
function siralamaDegisti(ham: string) {
setHata(null);
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
}
async function facetYukle(siraDeger: number, turDeger: string) {
setYukleniyor(true);
setHata(null);
try {
const res = await fetch(
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setSira(siraDeger);
setTur(turDeger);
setFacetler(veri.facetler);
setAdim("sihirbaz");
} catch {
setHata("Bir şeyler ters gitti, tekrar dener misin?");
} finally {
setYukleniyor(false);
}
}
async function giristenIlerle() {
if (sira != null) {
await facetYukle(sira, tur);
return;
}
setAdim("sira");
preloadSihirbazAdimlar();
}
async function siraGonder(e: React.FormEvent) {
e.preventDefault();
const value = Number(siralama.replace(/\./g, ""));
if (!value || value < 1 || value > 4_000_000) {
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
return;
}
await facetYukle(value, tur);
}
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (sira == null) return;
const yeniProfil: TercihProfili = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil);
const bekleyenVar = Boolean(bekleyen);
profilTamamlandi(yeniProfil);
if (duzenle) {
toast.success(
"Tercihlerin güncellendi. Listendeki programları yeni seçimlerine göre yeniden değerlendirdik; hiçbir programı silmedik.",
);
} else if (bekleyenVar) {
toast.success(
"Program listene eklendi. Sıralamana ve tercihlerine göre değerlendirdik.",
);
} else {
toast.success("Tercihlerin kaydedildi.");
}
}
if (adim === "giris") {
return (
<div className="flex flex-col gap-5">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
{duzenle ? "Tercihlerini güncelle" : "Önce seni biraz tanıyalım"}
</p>
<h2 className="mt-2 font-heading text-2xl font-bold text-slate-900">
{duzenle
? "Seçimlerini değiştir"
: "Tercihine ışık tutabilmemiz için önce ne istediğini öğrenelim"}
</h2>
<p className="mt-3 max-w-xl text-sm leading-6 text-slate-600">
{duzenle
? "İlgi alanların, şehirlerin veya önceliklerin değiştiyse güncelle. Mevcut programların listede kalır; yalnızca değerlendirmeler yenilenir."
: "Sadece sıralamana uyan bir liste değil, ne istediğine de uyan bir tercih listesi kurmak istiyoruz. Birkaç kısa seçim yap; eklediğin programların sana ne kadar uyduğunu birlikte değerlendirelim."}
</p>
{bekleyen ? (
<p className="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
Bekleyen program:{" "}
<span className="font-semibold">{bekleyen.program.isim}</span>
<span className="text-slate-500">
{" "}
· {bekleyen.program.universite}
</span>
</p>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={profilKapisiniKapat}
className="cursor-pointer"
>
Vazgeç
</Button>
<Button
type="button"
disabled={yukleniyor}
onClick={() => void giristenIlerle()}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"}
</Button>
</div>
</div>
);
}
if (adim === "sira") {
return (
<div className="flex flex-col gap-5">
<div>
<h2 className="font-heading text-xl font-bold text-slate-900">
Başarı sıralaman nedir?
</h2>
<p className="mt-1 text-sm text-slate-500">
Risk ve erişilebilir programlar sıralamana göre hesaplanır.
</p>
</div>
<div
role="radiogroup"
aria-label="Puan türü"
className="flex flex-wrap gap-1.5"
>
{PUAN_TURLERI_SECENEK.map((t) => (
<button
key={t.deger}
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<form onSubmit={siraGonder} className="flex flex-col gap-3 sm:flex-row">
<label htmlFor="kapisi-siralama" className="sr-only">
YKS başarı sıralaması
</label>
<Input
id="kapisi-siralama"
inputMode="numeric"
autoComplete="off"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
onFocus={preloadSihirbazAdimlar}
aria-invalid={hata ? true : undefined}
className="h-12 bg-background sm:flex-1"
/>
<Button
type="submit"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Devam"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
{hata ? (
<p role="alert" className="text-sm font-medium text-red-600">
{hata}
</p>
) : null}
<div className="flex justify-start border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setAdim("giris")}
className="cursor-pointer"
>
Geri
</Button>
</div>
</div>
);
}
if (facetler && sira != null) {
return (
<div className="flex flex-col gap-4">
{hata ? (
<p role="alert" className="text-sm font-medium text-red-600">
{hata}
</p>
) : null}
<SihirbazAdimlarLazy
key={`${sira}-${tur}-${duzenle ? "duzenle" : "yeni"}`}
sira={sira}
tur={tur}
facetler={facetler}
baslangicSecimler={duzenle ? profil?.secimler : null}
sonButonEtiketi={
duzenle
? "Tercihlerimi güncelle"
: bekleyen
? "Kaydet ve programı ekle"
: "Tercihlerimi kaydet"
}
onTamamla={secimleriTamamla}
/>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,92 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import type { Program } from "@/lib/db";
export function programEtkinSira(program: Program): number | null {
return program.sira2025 ?? program.sira2024;
}
/** 20212025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: Program }) {
const yillar = [
[2021, program.sira2021],
[2022, program.sira2022],
[2023, program.sira2023],
[2024, program.sira2024],
[2025, program.sira2025],
].filter((yil): yil is [number, number] => yil[1] != null);
if (yillar.length < 2) return <span className="text-slate-400"></span>;
const siralar = yillar.map(([, sira]) => sira);
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const genislik = 56;
const yukseklik = 18;
const bosluk = 2;
const noktalar = yillar.map(([yil, sira], index) => {
const x =
bosluk +
(index / (yillar.length - 1)) * (genislik - bosluk * 2);
const y =
max === min
? yukseklik / 2
: bosluk +
((sira - min) / (max - min)) * (yukseklik - bosluk * 2);
return { x, y, yil, sira };
});
const son = noktalar[noktalar.length - 1];
const ozet = yillar
.map(([yil, sira]) => `${yil}: ~${sira.toLocaleString("tr-TR")}.`)
.join("\n");
return (
<svg
viewBox={`0 0 ${genislik} ${yukseklik}`}
className="inline-block h-4.5 w-14 text-slate-300"
role="img"
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
>
<title>{ozet}</title>
<polyline
points={noktalar.map((nokta) => `${nokta.x},${nokta.y}`).join(" ")}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
</svg>
);
}
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
export function ProgramTabanTrendi({ program }: { program: Program }) {
if (program.sira2025 == null || program.sira2024 == null) return null;
const oran = (program.sira2024 - program.sira2025) / program.sira2024;
if (Math.abs(oran) < 0.1) return null;
const gecis = `${program.sira2024.toLocaleString("tr-TR")}${program.sira2025.toLocaleString("tr-TR")}`;
if (oran > 0) {
return (
<TrendingUp
className="size-3 shrink-0 text-amber-500"
aria-label={`Taban yükseliyor: ${gecis}`}
role="img"
>
<title>{`Taban yükseliyor: ${gecis}`}</title>
</TrendingUp>
);
}
return (
<TrendingDown
className="size-3 shrink-0 text-emerald-600"
aria-label={`Taban geriliyor: ${gecis}`}
role="img"
>
<title>{`Taban geriliyor: ${gecis}`}</title>
</TrendingDown>
);
}

View File

@@ -9,18 +9,19 @@ import { useState } from "react";
import Link from "next/link";
import { useLinkStatus } from "next/link";
import {
Check,
ChevronDown,
Plus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
} from "@/components/program-liste-verileri";
import {
Table,
TableBody,
@@ -30,12 +31,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
MANUEL_LISTE_MAX,
ekle,
useManuelListe,
type ManuelTercih,
} from "@/components/manuel-liste/store";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
const DILIMLER: {
key: DilimKey;
@@ -128,22 +124,6 @@ function PuanTuruSecici({
);
}
function efektifSira(p: Program): number | null {
return p.sira2025 ?? p.sira2024;
}
function tercihYap(p: Program, adaySira: number): ManuelTercih {
const taban = efektifSira(p);
return {
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
efektifSira: taban,
risk: riskHesapla(taban, adaySira),
};
}
const BOS_EKSTRA: Record<DilimKey, Program[]> = {
hayal: [],
dengeli: [],
@@ -167,12 +147,9 @@ export function ProgramTablosu({
adaySira: number;
tur: PuanTuruKey;
}) {
const liste = useManuelListe();
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
varsayilanDilim(sonuclar),
);
const listedekiler = new Set(liste.map((t) => t.id));
const doldu = liste.length >= MANUEL_LISTE_MAX;
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür değişince
// (puan türü seçici aynı bileşeni yeniden render eder, remount etmez)
@@ -190,10 +167,6 @@ export function ProgramTablosu({
}
}
function eklemeyiDene(p: Program) {
ekle(tercihYap(p, adaySira));
}
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim);
try {
@@ -300,9 +273,7 @@ export function ProgramTablosu({
key={p.id}
program={p}
adaySira={adaySira}
listede={listedekiler.has(p.id)}
doldu={doldu}
onEkle={eklemeyiDene}
tur={tur}
/>
))}
</TableBody>
@@ -334,86 +305,6 @@ export function ProgramTablosu({
);
}
// Tabanın 20212025 seyri: küçük sıra = daha rekabetçi olduğundan y ekseni
// ters çevrilir; çizgi yukarı gidiyorsa bölüm yıllar içinde zorlaşıyor demektir.
function SiraGecmisi({ p }: { p: Program }) {
const yillar = [
[2021, p.sira2021],
[2022, p.sira2022],
[2023, p.sira2023],
[2024, p.sira2024],
[2025, p.sira2025],
].filter((y): y is [number, number] => y[1] != null);
if (yillar.length < 2) return <span className="text-slate-400"></span>;
const siralar = yillar.map(([, s]) => s);
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const W = 56;
const H = 18;
const PAD = 2;
const noktalar = yillar.map(([yil, s], i) => {
const x = PAD + (i / (yillar.length - 1)) * (W - PAD * 2);
// min sıra (en rekabetçi) üstte dursun
const y =
max === min
? H / 2
: PAD + ((s - min) / (max - min)) * (H - PAD * 2);
return { x, y, yil, s };
});
const son = noktalar[noktalar.length - 1];
const ozet = yillar
.map(([yil, s]) => `${yil}: ~${s.toLocaleString("tr-TR")}.`)
.join("\n");
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="inline-block h-[18px] w-14 text-slate-300"
role="img"
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
>
<title>{ozet}</title>
<polyline
points={noktalar.map((n) => `${n.x},${n.y}`).join(" ")}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
</svg>
);
}
// Taban sıra iki yıl üst üste %10'dan fazla oynadıysa yönünü gösterir;
// küçük sıra = daha rekabetçi taban demektir.
function TabanTrendi({ p }: { p: Program }) {
if (p.sira2025 == null || p.sira2024 == null) return null;
const oran = (p.sira2024 - p.sira2025) / p.sira2024;
if (Math.abs(oran) < 0.1) return null;
const yukseliyor = oran > 0;
const gecis = `${p.sira2024.toLocaleString("tr-TR")}${p.sira2025.toLocaleString("tr-TR")}`;
return yukseliyor ? (
<TrendingUp
className="size-3 shrink-0 text-amber-500"
aria-label={`Taban yükseliyor: ${gecis}`}
role="img"
>
<title>{`Taban yükseliyor: ${gecis}`}</title>
</TrendingUp>
) : (
<TrendingDown
className="size-3 shrink-0 text-emerald-600"
aria-label={`Taban geriliyor: ${gecis}`}
role="img"
>
<title>{`Taban geriliyor: ${gecis}`}</title>
</TrendingDown>
);
}
// /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
// kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek
// için burada tekrarlanır. Katalog haritası DB'deki tüm universite
@@ -425,17 +316,13 @@ function uniSayfaSlug(ad: string): string {
function ProgramSatiri({
program: p,
adaySira,
listede,
doldu,
onEkle,
tur,
}: {
program: Program;
adaySira: number;
listede: boolean;
doldu: boolean;
onEkle: (p: Program) => void;
tur: PuanTuruKey;
}) {
const taban = efektifSira(p);
const taban = programEtkinSira(p);
const risk = riskHesapla(taban, adaySira);
const devlet = p.unitur === "DEVLET";
@@ -466,7 +353,6 @@ function ProgramSatiri({
href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline"
title={`${p.universite} taban puanları sayfası`}
// Satırdaki "+" ekleme akışıyla karışmasın: tıklama satıra yayılmaz
onClick={(e) => e.stopPropagation()}
>
{p.universite}
@@ -479,7 +365,7 @@ function ProgramSatiri({
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<SiraGecmisi p={p} />
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell
className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"
@@ -504,39 +390,18 @@ function ProgramSatiri({
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<TabanTrendi p={p} />
<ProgramTabanTrendi program={p} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
disabled={listede || doldu}
onClick={() => onEkle(p)}
title={
listede
? "Zaten seçtiklerinde"
: doldu
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Seçtiklerime ekle"
}
className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default ${
listede
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
}`}
>
{listede ? (
<Check className="size-4" aria-hidden />
) : (
<Plus className="size-4" aria-hidden />
)}
<span className="sr-only">
{listede
? `${p.isim} seçtiklerinde`
: `${p.isim} seçtiklerine ekle`}
</span>
</button>
<ProgramEkleButonu
program={p}
kaynak="sonuc"
adaySira={adaySira}
adayTur={tur}
className="size-8 min-h-8 min-w-8"
/>
</TableCell>
</TableRow>
);

View File

@@ -1,4 +1,3 @@
import { siApple, siGooglemaps } from "simple-icons";
import { cities } from "turkey-map-react/lib/data";
import {
HARITA_GENISLIK,
@@ -7,34 +6,6 @@ import {
normalizeIlAdi,
uniKonum,
} from "@/lib/harita";
import { trBaslikDuzeni } from "@/lib/slug";
function HaritaMarkaIkonu({ marka }: { marka: string }) {
const ikon =
marka === "Google" ? siGooglemaps : marka === "Apple" ? siApple : null;
if (ikon) {
return (
<svg
viewBox="0 0 24 24"
className="size-4"
style={{ color: `#${ikon.hex}` }}
aria-hidden
>
<path d={ikon.path} fill="currentColor" />
</svg>
);
}
return (
<span
className="flex size-4 items-center justify-center rounded-sm bg-[#fc3f1d] text-[11px] font-bold leading-none text-white"
aria-hidden
>
Я
</span>
);
}
export function UniversiteKonumHaritasi({
universite,
@@ -44,17 +15,7 @@ export function UniversiteKonumHaritasi({
il: string | null;
}) {
const konum = uniKonum(universite, il);
const ilAdi = il ? trBaslikDuzeni(il) : null;
const arama = `${universite}${ilAdi ? ` ${ilAdi}` : ""}`;
const sorgu = encodeURIComponent(arama);
const haritaBaglantilari = [
{
ad: "Google",
href: `https://www.google.com/maps/search/?api=1&query=${sorgu}`,
},
{ ad: "Yandex", href: `https://yandex.com.tr/maps/?text=${sorgu}` },
{ ad: "Apple", href: `https://maps.apple.com/?q=${sorgu}` },
];
if (!konum) return null;
return (
<section
@@ -65,92 +26,50 @@ export function UniversiteKonumHaritasi({
{universite} kampüs konumu
</h2>
{konum ? (
<div className="flex h-full min-h-44 items-start gap-2">
<div className="relative h-full min-h-44 min-w-0 flex-1">
<svg
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
>
{cities.map((city) => {
const secili = il ? normalizeIlAdi(city.name) === il : false;
return (
<path
key={city.id}
d={city.path}
fill={
secili
? "oklch(0.809 0.105 251.8)"
: "oklch(0.929 0.013 255.5)"
}
stroke="white"
strokeWidth={secili ? 1.8 : 1}
>
<title>{city.name}</title>
</path>
);
})}
<g aria-hidden>
<circle
cx={konum.x}
cy={konum.y}
r={18}
fill="oklch(0.623 0.188 259.8 / 18%)"
/>
<circle
cx={konum.x}
cy={konum.y}
r={9}
fill="oklch(0.623 0.188 259.8)"
stroke="white"
strokeWidth={3}
/>
<circle cx={konum.x} cy={konum.y} r={2.5} fill="white" />
</g>
</svg>
</div>
<div
className="flex shrink-0 flex-col items-end gap-1"
aria-label="Harita uygulamaları"
>
{haritaBaglantilari.map((baglanti) => (
<a
key={baglanti.ad}
href={baglanti.href}
target="_blank"
rel="noreferrer"
title={`${baglanti.ad} Haritalar'da aç`}
aria-label={`${universite} konumunu ${baglanti.ad} Haritalar'da aç`}
className="inline-flex size-11 items-center justify-center rounded-full bg-white/95 shadow-sm backdrop-blur transition-colors duration-200 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<HaritaMarkaIkonu marka={baglanti.ad} />
</a>
))}
</div>
</div>
) : (
<div
className="flex h-full min-h-44 flex-col items-end justify-center gap-1"
aria-label="Harita uygulamaları"
<div className="relative h-full min-h-44 w-full">
<svg
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
>
{haritaBaglantilari.map((baglanti) => (
<a
key={baglanti.ad}
href={baglanti.href}
target="_blank"
rel="noreferrer"
title={`${baglanti.ad} Haritalar'da aç`}
aria-label={`${universite} konumunu ${baglanti.ad} Haritalar'da aç`}
className="inline-flex size-11 items-center justify-center rounded-full bg-white shadow-sm transition-colors duration-200 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<HaritaMarkaIkonu marka={baglanti.ad} />
</a>
))}
</div>
)}
{cities.map((city) => {
const secili = il ? normalizeIlAdi(city.name) === il : false;
return (
<path
key={city.id}
d={city.path}
fill={
secili
? "oklch(0.809 0.105 251.8)"
: "oklch(0.929 0.013 255.5)"
}
stroke="white"
strokeWidth={secili ? 1.8 : 1}
>
<title>{city.name}</title>
</path>
);
})}
<g aria-hidden>
<circle
cx={konum.x}
cy={konum.y}
r={18}
fill="oklch(0.623 0.188 259.8 / 18%)"
/>
<circle
cx={konum.x}
cy={konum.y}
r={9}
fill="oklch(0.623 0.188 259.8)"
stroke="white"
strokeWidth={3}
/>
<circle cx={konum.x} cy={konum.y} r={2.5} fill="white" />
</g>
</svg>
</div>
</section>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import type { Program } from "@/lib/db";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const sayi = (n: number) => n.toLocaleString("tr-TR");
const TUR_ETIKET: Record<string, string> = {
SAYISAL: "SAY",
"EŞİT AĞIRLIK": "EA",
SÖZEL: "SÖZ",
DİL: "DİL",
TYT: "TYT",
};
export type UniProgramSatiri = Program & { bolumSlug: string | null };
export function UniversiteProgramTablosu({
gruplar,
}: {
gruplar: { fakulte: string; programlar: UniProgramSatiri[] }[];
}) {
return (
<>
{gruplar.map(({ fakulte, programlar }, i) => (
<section key={`${fakulte}-${i}`} className="mt-6">
<h2 className="mb-3 font-heading text-xl font-bold text-slate-900">
{fakulte}
</h2>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
2025 puan
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programlar.map((p) => {
const taban = programEtkinSira(p);
return (
<TableRow key={p.id}>
<TableCell className="max-w-0 w-full">
{p.bolumSlug ? (
<Link
href={`/bolum/${p.bolumSlug}`}
className="block truncate text-sm font-semibold text-slate-900 hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{p.isim.trim()}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{p.isim.trim()}
</span>
)}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<span className="shrink-0 rounded bg-slate-100 px-1 py-px text-[10px] font-medium leading-4 text-slate-600">
{TUR_ETIKET[p.tur] ?? p.tur}
</span>
{p.sure ? (
<span className="shrink-0">{p.sure} yıl</span>
) : null}
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
{p.kontenjan2025 != null
? `${sayi(p.kontenjan2025)} kişi`
: "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={p} />
{taban != null ? `~${sayi(taban)}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="universite"
className="size-8 min-h-8 min-w-8"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</section>
))}
</>
);
}

View File

@@ -34,6 +34,12 @@ export interface SihirbazSecimleri {
oncelikler: string[];
}
export interface TercihProfili {
sira: number;
tur: string;
secimler: SihirbazSecimleri;
}
const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"];
/**
@@ -90,6 +96,44 @@ export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null {
return { kategoriler, iller, universiteTipi, oncelikler };
}
/** localStorage'taki ortak sıra + sihirbaz profilini güvenle doğrular. */
export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null {
if (typeof girdi !== "object" || girdi === null) return null;
const p = girdi as Record<string, unknown>;
const sira = typeof p.sira === "number" ? p.sira : Number(p.sira);
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) return null;
if (typeof p.tur !== "string" || p.tur.trim().length === 0) return null;
const secimler = sihirbazDogrula(p.secimler);
if (!secimler) return null;
return { sira, tur: p.tur, secimler };
}
/** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */
export function tercihProfiliOku(): TercihProfili | null {
if (typeof window === "undefined") return null;
try {
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
if (!ham) return null;
return tercihProfiliDogrula(JSON.parse(ham));
} catch {
return null;
}
}
/** Ortak profili yazar; hero ve AI sihirbazıyla aynı anahtarı kullanır. */
export function tercihProfiliYaz(profil: TercihProfili): void {
if (typeof window === "undefined") return;
const dogru = tercihProfiliDogrula(profil);
if (!dogru) return;
try {
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
} catch {}
}
export function tercihProfiliTamamlandiMi(): boolean {
return tercihProfiliOku() !== null;
}
/** Prompt'a enjekte edilecek Türkçe özet. */
export function secimOzeti(secimler: SihirbazSecimleri): string {
const satirlar = [

View File

@@ -0,0 +1,127 @@
import { kategoriEslesir } from "./kategoriler";
import { riskHesapla, type RiskSeviyesi } from "./risk";
import type { TercihProfili } from "./sihirbaz";
export type DegerlendirilecekProgram = {
isim: string;
il: string | null;
unitur?: string | null;
efektifSira: number | null;
};
export type TercihDegerlendirmesi = {
risk: RiskSeviyesi | null;
riskEtiketi: "Hayal" | "Dengeli" | "Daha güvenli" | "Veri yetersiz";
riskMetni: string;
olumlu: string[];
celiskiler: string[];
};
function normalize(deger: string): string {
return deger.trim().toLocaleUpperCase("tr-TR");
}
export function tercihDegerlendir(
program: DegerlendirilecekProgram,
profil: TercihProfili,
): TercihDegerlendirmesi {
const risk = riskHesapla(program.efektifSira, profil.sira);
const olumlu: string[] = [];
const celiskiler: string[] = [];
if (kategoriEslesir(program.isim, profil.secimler.kategoriler)) {
olumlu.push("İlgi alanlarınla uyumlu");
} else {
celiskiler.push("Seçtiğin ilgi alanlarının dışında görünüyor");
}
if (profil.secimler.iller.length > 0) {
const seciliIller = new Set(profil.secimler.iller.map(normalize));
if (program.il && seciliIller.has(normalize(program.il))) {
olumlu.push("Tercih ettiğin şehirlerden birinde");
} else {
celiskiler.push("Tercih ettiğin şehirlerin dışında");
}
}
if (profil.secimler.universiteTipi !== "farketmez" && program.unitur) {
const devlet = normalize(program.unitur) === "DEVLET";
const uyuyor =
(profil.secimler.universiteTipi === "devlet" && devlet) ||
(profil.secimler.universiteTipi === "vakif" && !devlet);
if (uyuyor) olumlu.push("Üniversite türü tercihine uyuyor");
else celiskiler.push("Üniversite türü tercihinle uyuşmuyor");
}
if (profil.secimler.oncelikler.includes("Kolay yerleşme")) {
if (risk === "guvenli") {
olumlu.push("Daha güvenli seçenek önceliğini destekliyor");
} else if (risk === "riskli") {
celiskiler.push("Kolay yerleşme önceliğine göre iddialı bir seçim");
}
}
const riskEtiketi =
risk === "riskli"
? "Hayal"
: risk === "az-riskli"
? "Dengeli"
: risk === "guvenli"
? "Daha güvenli"
: "Veri yetersiz";
const riskMetni =
risk === "riskli"
? "Geçen yılın taban sıralaması senin sıralamandan daha yukarıda."
: risk === "az-riskli"
? "Geçmiş taban sıralamasına göre dengeli aralıkta."
: risk === "guvenli"
? "Geçmiş taban sıralamasına göre daha güvenli aralıkta."
: "Yerleşme riskini hesaplamak için yeterli taban sıra verisi yok.";
return {
risk,
riskEtiketi,
riskMetni,
olumlu: olumlu.slice(0, 3),
celiskiler: celiskiler.slice(0, 2),
};
}
export function tercihListesiOzeti(
programlar: DegerlendirilecekProgram[],
profil: TercihProfili,
) {
const degerlendirmeler = programlar.map((p) => tercihDegerlendir(p, profil));
const hayal = degerlendirmeler.filter((d) => d.risk === "riskli").length;
const dengeli = degerlendirmeler.filter(
(d) => d.risk === "az-riskli",
).length;
const dahaGuvenli = degerlendirmeler.filter(
(d) => d.risk === "guvenli",
).length;
const dikkat = degerlendirmeler.filter(
(d) => d.celiskiler.length > 0,
).length;
const gucluUyum = degerlendirmeler.filter(
(d) => d.olumlu.length >= 2 && d.celiskiler.length === 0,
).length;
let oneri = "Yeni seçimler ekledikçe listenin dengesi burada görünecek.";
if (programlar.length > 0 && dahaGuvenli === 0) {
oneri =
"Listen iddialı seçeneklerde yoğunlaşıyor. Birkaç daha güvenli program eklemek riski dağıtabilir.";
} else if (programlar.length > 2 && dengeli === 0) {
oneri =
"Listen için dengeli aralıkta birkaç program eklemek seçeneklerini güçlendirebilir.";
} else if (dikkat > Math.max(1, programlar.length / 2)) {
oneri =
"Seçtiklerinin çoğu profilinle ayrışıyor. Dikkat notlarını birlikte gözden geçir.";
} else if (programlar.length >= 24) {
oneri =
"24 tercihe ulaştın. Şimdi risk dağılımını ve dikkat notlarını gözden geçir.";
} else if (programlar.length > 0) {
oneri = "Listen dengeleniyor; uyum ve risk dağılımını koruyarak ilerle.";
}
return { hayal, dengeli, dahaGuvenli, dikkat, gucluUyum, oneri };
}