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
All checks were successful
Deploy / deploy (push) Successful in 7m25s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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ı aç
|
||||
</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ı aç
|
||||
|
||||
190
src/components/manuel-liste/profil-kapisi-store.ts
Normal file
190
src/components/manuel-liste/profil-kapisi-store.ts
Normal 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;
|
||||
}
|
||||
89
src/components/manuel-liste/program-ekle-butonu.tsx
Normal file
89
src/components/manuel-liste/program-ekle-butonu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
270
src/components/manuel-liste/secimlerim-paneli.tsx
Normal file
270
src/components/manuel-liste/secimlerim-paneli.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
319
src/components/manuel-liste/tercih-profili-kapisi.tsx
Normal file
319
src/components/manuel-liste/tercih-profili-kapisi.tsx
Normal 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;
|
||||
}
|
||||
92
src/components/program-liste-verileri.tsx
Normal file
92
src/components/program-liste-verileri.tsx
Normal 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;
|
||||
}
|
||||
|
||||
/** 2021–2025 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>
|
||||
);
|
||||
}
|
||||
@@ -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 2021–2025 seyri: küçük sıra = daha rekabetçi olduğundan y ekseni
|
||||
// ters çevrilir; çizgi yukarı gidiyorsa bölüm yıllar içinde zorlaşıyor demektir.
|
||||
function SiraGecmisi({ p }: { p: Program }) {
|
||||
const yillar = [
|
||||
[2021, p.sira2021],
|
||||
[2022, p.sira2022],
|
||||
[2023, p.sira2023],
|
||||
[2024, p.sira2024],
|
||||
[2025, p.sira2025],
|
||||
].filter((y): y is [number, number] => y[1] != null);
|
||||
if (yillar.length < 2) return <span className="text-slate-400">—</span>;
|
||||
|
||||
const siralar = yillar.map(([, s]) => s);
|
||||
const min = Math.min(...siralar);
|
||||
const max = Math.max(...siralar);
|
||||
const W = 56;
|
||||
const H = 18;
|
||||
const PAD = 2;
|
||||
const noktalar = yillar.map(([yil, s], i) => {
|
||||
const x = PAD + (i / (yillar.length - 1)) * (W - PAD * 2);
|
||||
// min sıra (en rekabetçi) üstte dursun
|
||||
const y =
|
||||
max === min
|
||||
? H / 2
|
||||
: PAD + ((s - min) / (max - min)) * (H - PAD * 2);
|
||||
return { x, y, yil, s };
|
||||
});
|
||||
const son = noktalar[noktalar.length - 1];
|
||||
const ozet = yillar
|
||||
.map(([yil, s]) => `${yil}: ~${s.toLocaleString("tr-TR")}.`)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="inline-block h-[18px] w-14 text-slate-300"
|
||||
role="img"
|
||||
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
|
||||
>
|
||||
<title>{ozet}</title>
|
||||
<polyline
|
||||
points={noktalar.map((n) => `${n.x},${n.y}`).join(" ")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Taban sıra iki yıl üst üste %10'dan fazla oynadıysa yönünü gösterir;
|
||||
// küçük sıra = daha rekabetçi taban demektir.
|
||||
function TabanTrendi({ p }: { p: Program }) {
|
||||
if (p.sira2025 == null || p.sira2024 == null) return null;
|
||||
const oran = (p.sira2024 - p.sira2025) / p.sira2024;
|
||||
if (Math.abs(oran) < 0.1) return null;
|
||||
const yukseliyor = oran > 0;
|
||||
const gecis = `${p.sira2024.toLocaleString("tr-TR")} → ${p.sira2025.toLocaleString("tr-TR")}`;
|
||||
return yukseliyor ? (
|
||||
<TrendingUp
|
||||
className="size-3 shrink-0 text-amber-500"
|
||||
aria-label={`Taban yükseliyor: ${gecis}`}
|
||||
role="img"
|
||||
>
|
||||
<title>{`Taban yükseliyor: ${gecis}`}</title>
|
||||
</TrendingUp>
|
||||
) : (
|
||||
<TrendingDown
|
||||
className="size-3 shrink-0 text-emerald-600"
|
||||
aria-label={`Taban geriliyor: ${gecis}`}
|
||||
role="img"
|
||||
>
|
||||
<title>{`Taban geriliyor: ${gecis}`}</title>
|
||||
</TrendingDown>
|
||||
);
|
||||
}
|
||||
|
||||
// /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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
127
src/components/universite-program-tablosu.tsx
Normal file
127
src/components/universite-program-tablosu.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user