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

@@ -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>
))}
</>
);
}