Update UI components and database schema
All checks were successful
Deploy / deploy (push) Successful in 2m38s

- Updated the `page.tsx` component to enhance user experience with new UI elements and improved layout.
- Modified the `funnel-banner.tsx` to include dynamic text and improved styling for better engagement.
- Adjusted the `liste-paneli.tsx` to provide clearer information on user credits and package details.
- Enhanced the `rapor-listesi.tsx` to display additional data points for better insights.
- Updated the database schema in `app.db` to reflect recent changes in the application logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-07-26 05:17:17 +03:00
parent 209d203fe8
commit d93cf742ff
16 changed files with 1062 additions and 621 deletions

View File

@@ -5,7 +5,12 @@
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
import { cities } from "turkey-map-react/lib/data";
import { normalizeIlAdi } from "@/lib/harita";
import {
HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
export function IlSecimHaritasi({
iller,
@@ -24,7 +29,7 @@ export function IlSecimHaritasi({
return (
<div>
<svg
viewBox="0 0 1050 585"
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="h-auto w-full"
role="group"
aria-label="Okumak istediğin illeri haritadan seç"

View File

@@ -1,8 +1,7 @@
"use client";
// Manuel 24'lük listenin alt çekmecesi (bottom sheet) + "+"dan listeye uçan
// pill katmanı. Layout'ta bir kez monte edilir; navbar'daki Listem butonu
// açar/kapatır.
// Manuel 24'lük listenin alt çekmecesi (bottom sheet). Layout'ta bir kez
// monte edilir; navbar'daki Listem butonu açar/kapatır.
//
// Motion dili (apple-design / emil-design-eng):
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi.
@@ -10,9 +9,12 @@
// - Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
// sağa kaydırmak silme jesti — eşik aşılırsa satır uçar, altından kırmızı
// sil bandı görünür.
// - Uçuş pill'i momentumsuz doğduğu için sekmeden (bounce ~0) hedefe süzülür.
// - prefers-reduced-motion: uçuş yok, sheet cross-fade; silme için X butonu
// her durumda kalır.
// - Silme/temizlemede kart yüksekliği ve kalan satırlar layout projeksiyonuyla
// yumuşakça oturur ("lap diye" kısalma yok). Projeksiyon, sheet'in giriş
// spring'i BİTTİKTEN sonra açılır (oturdu state'i): transform'lu parent
// içinde ölçüm alan satırlar o offset'i kalıcı transform olarak taşıyordu.
// - prefers-reduced-motion: sheet cross-fade; silme için X butonu her
// durumda kalır.
import { useEffect, useState } from "react";
import Link from "next/link";
@@ -39,12 +41,10 @@ import {
cekmeceKapat,
cikar,
temizle,
ucusAbone,
useCekmeceAcik,
useManuelListe,
yenidenSirala,
type ManuelTercih,
type UcusIstegi,
} from "./store";
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
@@ -55,20 +55,27 @@ const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
};
export function ListeCekmecesi() {
const liste = useManuelListe();
const acik = useCekmeceAcik();
return <AnimatePresence>{acik ? <CekmeceSheet /> : null}</AnimatePresence>;
}
function CekmeceSheet() {
const liste = useManuelListe();
const azMotion = useReducedMotion();
const sheetKontrol = useDragControls();
// Giriş spring'i bitmeden satırların layout projeksiyonu kapalı kalır —
// aksi halde transform'lu sheet içinde alınan ölçüm satırları kaydırıyor
const [oturdu, setOturdu] = useState(false);
// Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar
useEffect(() => {
if (!acik) return;
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") cekmeceKapat();
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, [acik]);
}, []);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
@@ -77,151 +84,159 @@ export function ListeCekmecesi() {
};
return (
<>
<UcusKatmani />
<AnimatePresence>
{acik ? (
<motion.aside
key="cekmece"
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
y: "calc(100% + 16px)",
transition: {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
},
}
<motion.aside
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
y: "calc(100% + 16px)",
transition: {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
},
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
// layoutRoot: sheet'in kendi y-transform animasyonu, içindeki
// Reorder satırlarının layout ölçümünü bozmasın (satırlar aksi
// halde giriş anındaki offset'i kalıcı transform olarak taşıyor)
layoutRoot
drag={azMotion ? false : "y"}
dragListener={false}
dragControls={sheetKontrol}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={surukleyinceKapat}
aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
>
<div className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<div
onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e);
}}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
onAnimationComplete={() => setOturdu(true)}
drag={azMotion ? false : "y"}
dragListener={false}
dragControls={sheetKontrol}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={surukleyinceKapat}
aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
>
{/* layout: satır silinince/eklenince kart yüksekliği spring ile oturur.
Doğrudan çocuklar layout="position" taşır ki ölçekleme sırasında
içerik yamulmasın (scale-correction zinciri). */}
<motion.div
layout={oturdu}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<motion.div
layout="position"
onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e);
}}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
>
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</motion.div>
{/* Başlık + doluluk */}
<motion.div
layout="position"
className="border-b border-slate-100 px-5 pb-4 pt-1"
>
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Listem
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
title="Listeyi temizle"
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<button
type="button"
onClick={cekmeceKapat}
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</div>
{/* Başlık + doluluk */}
<div className="border-b border-slate-100 px-5 pb-4 pt-1">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Listem
<span
data-cekmece-hedef
className="font-heading text-sm font-bold tabular-nums text-slate-400"
>
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
title="Listeyi temizle"
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<button
type="button"
onClick={cekmeceKapat}
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</button>
</div>
</div>
<div
className="mt-3 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"
>
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${doluluk}%` }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</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.
</p>
</div>
{/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center">
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Listen henüz boş
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; 24 tercihlik listen burada birikir.
</p>
</div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layoutScroll
as="ol"
className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-4"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri key={t.id} tercih={t} sira={i + 1} />
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Alt bant: AI danışman köprüsü */}
<div className="border-t border-slate-100 px-5 py-4">
<Link
href="/listem"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı
</Link>
</div>
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</button>
</div>
</motion.aside>
) : null}
</AnimatePresence>
</>
</div>
<div
className="mt-3 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"
>
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${doluluk}%` }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</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.
</p>
</motion.div>
{/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? (
<motion.div
layout="position"
className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center"
>
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Listen henüz boş
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; 24 tercihlik listen burada birikir.
</p>
</motion.div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layout="position"
layoutScroll
as="ol"
className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto px-4 py-4"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Alt bant: AI danışman köprüsü */}
<motion.div
layout="position"
className="border-t border-slate-100 px-5 py-4"
>
<Link
href="/listem"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı
</Link>
</motion.div>
</motion.div>
</motion.aside>
);
}
@@ -231,9 +246,11 @@ const SIL_ESIK = 90;
function CekmeceSatiri({
tercih,
sira,
projeksiyonAcik,
}: {
tercih: ManuelTercih;
sira: number;
projeksiyonAcik: boolean;
}) {
const stil = tercih.risk ? RISK_STIL[tercih.risk] : null;
const siraKontrol = useDragControls();
@@ -255,8 +272,8 @@ function CekmeceSatiri({
<Reorder.Item
value={tercih}
as="li"
layout="position"
layoutId={`manuel-tercih-${tercih.id}`}
layout={projeksiyonAcik ? "position" : undefined}
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
dragListener={false}
dragControls={siraKontrol}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
@@ -338,74 +355,3 @@ function CekmeceSatiri({
</Reorder.Item>
);
}
// ---------------------------------------------------------------------------
// Uçuş katmanı: "+" butonundan Listem hedefine süzülen pill
// ---------------------------------------------------------------------------
type Ucus = UcusIstegi & { id: number; hedef: { x: number; y: number } };
let ucusSayac = 0;
/** Hedef nokta: çekmece açıksa içindeki sayaç, değilse navbar'daki buton. */
function hedefBul(): { x: number; y: number } | null {
const el =
document.querySelector("[data-cekmece-hedef]") ??
document.querySelector("[data-listem-hedef]");
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
function UcusKatmani() {
const [ucuslar, setUcuslar] = useState<Ucus[]>([]);
const azMotion = useReducedMotion();
useEffect(() => {
if (azMotion) return; // reduced motion: rozet pulse yeter
return ucusAbone((istek) => {
const hedef = hedefBul();
if (!hedef) return;
setUcuslar((u) => [...u, { ...istek, id: ucusSayac++, hedef }]);
});
}, [azMotion]);
if (ucuslar.length === 0) return null;
return (
<div
className="pointer-events-none fixed inset-0 z-[70] print:hidden"
aria-hidden
>
{ucuslar.map((u) => (
<motion.div
key={u.id}
initial={{ x: u.kaynak.x, y: u.kaynak.y, scale: 1, opacity: 1 }}
animate={{
x: u.hedef.x,
y: u.hedef.y,
scale: 0.3,
opacity: 0.4,
}}
transition={{ type: "spring", duration: 0.55, bounce: 0 }}
onAnimationComplete={() =>
setUcuslar((mevcut) => mevcut.filter((x) => x.id !== u.id))
}
className="absolute left-0 top-0"
>
{/* Merkezleme iç sarmalayıcıda: motion'ın x/y transformu ile çakışmasın */}
<div className="flex max-w-56 -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-slate-200 bg-white py-1.5 pl-3 pr-4 shadow-lg">
{u.tercih.risk ? (
<span
className={`size-2 shrink-0 rounded-full ${RISK_STIL[u.tercih.risk].nokta}`}
/>
) : null}
<span className="truncate text-xs font-semibold text-slate-700">
{u.tercih.isim}
</span>
</div>
</motion.div>
))}
</div>
);
}

View File

@@ -1,8 +1,7 @@
"use client";
// Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca
// çekmeceyi açar/kapatır. Uçuş animasyonunun kapalı-çekmece hedefi de budur
// (data-listem-hedef). Sayaç değişince rozet spring ile "pulse" yapar.
// çekmeceyi açar/kapatır. Sayaç değişince rozet spring ile "pulse" yapar.
import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
@@ -21,7 +20,6 @@ export function ListemButonu() {
return (
<button
type="button"
data-listem-hedef
onClick={cekmeceDegistir}
aria-expanded={acik}
aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`}

View File

@@ -22,13 +22,6 @@ export type ManuelTercih = {
risk: RiskSeviyesi | null;
};
/** "+" butonundan listeye uçan pill'in tek seferlik isteği */
export type UcusIstegi = {
tercih: ManuelTercih;
/** Kaynak butonun viewport koordinatları */
kaynak: { x: number; y: number };
};
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
const BOS_LISTE: ManuelTercih[] = [];
@@ -37,7 +30,6 @@ let cekmeceAcik = false;
let yuklendi = false;
const dinleyiciler = new Set<() => void>();
const ucusDinleyiciler = new Set<(istek: UcusIstegi) => void>();
function yayinla() {
for (const cb of dinleyiciler) cb();
@@ -154,15 +146,3 @@ export function cekmeceDegistir() {
cekmeceAcik = !cekmeceAcik;
yayinla();
}
/** "+" butonu uçuş animasyonu tetikler; katman (liste-cekmecesi) dinler. */
export function ucusBaslat(istek: UcusIstegi) {
for (const cb of ucusDinleyiciler) cb(istek);
}
export function ucusAbone(cb: (istek: UcusIstegi) => void) {
ucusDinleyiciler.add(cb);
return () => {
ucusDinleyiciler.delete(cb);
};
}

View File

@@ -2,12 +2,21 @@
// Sonuç sayfasının AI'sız ham listesi: yalnızca başarı sıralamasıyla
// yokatlas'tan gelen programlar, dilim sekmeleri halinde. Her satırın "+"
// butonu programı manuel 24'lük listeye ekler; pill navbar'daki (ya da açık
// çekmecedeki) Listem hedefine uçar (bkz. manuel-liste/liste-cekmecesi).
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
import { useState } from "react";
import { Check, Plus, Rocket, Scale, ShieldCheck } from "lucide-react";
import type { Program, RankResults } from "@/lib/db";
import Link from "next/link";
import { useLinkStatus } from "next/link";
import {
Check,
Plus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import {
Table,
@@ -21,7 +30,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
MANUEL_LISTE_MAX,
ekle,
ucusBaslat,
useManuelListe,
type ManuelTercih,
} from "@/components/manuel-liste/store";
@@ -65,6 +73,61 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
riskli: "bg-red-500",
};
// Seçicideki kısa etiketler — sayfa başlığındaki uzun hallerinden bağımsız
const TUR_ETIKETLERI: Record<PuanTuruKey, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT",
};
// useLinkStatus Link'in altındaki bir bileşende çağrılmalı; sunucu
// gidiş-dönüşü sürerken tıklanan pill hafifçe nabız atar (layout shift yok).
function SeciciEtiket({ children }: { children: React.ReactNode }) {
const { pending } = useLinkStatus();
return (
<span
className={`transition-opacity duration-150 ${
pending ? "animate-pulse opacity-60" : ""
}`}
>
{children}
</span>
);
}
function PuanTuruSecici({
sira,
aktif,
}: {
sira: number;
aktif: PuanTuruKey;
}) {
return (
<nav
aria-label="Puan türü"
className="flex w-fit max-w-full flex-wrap gap-1 rounded-full border border-slate-200 bg-slate-100 p-1"
>
{(Object.keys(TUR_ETIKETLERI) as PuanTuruKey[]).map((key) => (
<Link
key={key}
href={`/sonuc?sira=${sira}&tur=${key}`}
scroll={false}
aria-current={key === aktif ? "page" : undefined}
className={`cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-[background-color,color,transform] duration-150 active:scale-[0.97] ${
key === aktif
? "bg-white text-slate-900 shadow-sm"
: "text-slate-500 hover:text-slate-900"
}`}
>
<SeciciEtiket>{TUR_ETIKETLERI[key]}</SeciciEtiket>
</Link>
))}
</nav>
);
}
function efektifSira(p: Program): number | null {
return p.sira2025 ?? p.sira2024;
}
@@ -84,40 +147,34 @@ function tercihYap(p: Program, adaySira: number): ManuelTercih {
export function ProgramTablosu({
sonuclar,
adaySira,
tur,
}: {
sonuclar: RankResults;
adaySira: number;
tur: PuanTuruKey;
}) {
const liste = useManuelListe();
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
const listedekiler = new Set(liste.map((t) => t.id));
const doldu = liste.length >= MANUEL_LISTE_MAX;
function eklemeyiDene(p: Program, buton: HTMLElement) {
const tercih = tercihYap(p, adaySira);
if (!ekle(tercih)) return;
const r = buton.getBoundingClientRect();
ucusBaslat({
tercih,
kaynak: { x: r.left + r.width / 2, y: r.top + r.height / 2 },
});
function eklemeyiDene(p: Program) {
ekle(tercihYap(p, adaySira));
}
return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini kendi 24&apos;lük listene at.
</span>
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini kendi 24&apos;lük listene at.
</span>
</p>
</div>
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
@@ -130,17 +187,29 @@ export function ProgramTablosu({
onValueChange={(v) => setAktifDilim(v as DilimKey)}
className="mt-5"
>
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar[d.key].length}
</span>
</TabsTrigger>
))}
</TabsList>
{/* Kontrol çubuğu: tabloyu süzen her şey tablonun hemen üstünde —
solda dilim sekmeleri (birincil), sağda puan türü (ikincil filtre).
Dar ekranda alt alta sarar. */}
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar[d.key].length}
</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
Puan türü
</span>
{/* scroll={false}: tür değişince sayfa konumu korunur */}
<PuanTuruSecici sira={adaySira} aktif={tur} />
</div>
</div>
{DILIMLER.map((d) => (
<TabsContent key={d.key} value={d.key}>
@@ -156,6 +225,12 @@ export function ProgramTablosu({
<TableRow>
<TableHead className="w-8" />
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-16 text-right">
<span className="sr-only">Listeye ekle</span>
@@ -184,6 +259,86 @@ 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>
);
}
function ProgramSatiri({
program: p,
adaySira,
@@ -195,10 +350,11 @@ function ProgramSatiri({
adaySira: number;
listede: boolean;
doldu: boolean;
onEkle: (p: Program, buton: HTMLElement) => void;
onEkle: (p: Program) => void;
}) {
const taban = efektifSira(p);
const risk = riskHesapla(taban, adaySira);
const devlet = p.unitur === "DEVLET";
return (
<TableRow>
@@ -212,19 +368,60 @@ function ProgramSatiri({
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="block truncate text-sm font-semibold">{p.isim}</span>
<span className="block truncate text-xs text-slate-500">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<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" : "Vakıf"}
</span>
<span className="truncate">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</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">
<SiraGecmisi p={p} />
</TableCell>
<TableCell
className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"
title={
p.kontenjan2025 != null && p.yerlesen2025 != null
? `${p.kontenjan2025} kontenjan, ${p.yerlesen2025} yerleşen`
: undefined
}
>
{p.kontenjan2025 != null ? (
<span className="inline-flex items-center gap-1.5">
{p.yerlesen2025 != null && p.yerlesen2025 < p.kontenjan2025 ? (
<span className="rounded bg-amber-50 px-1 py-px text-[10px] font-medium leading-4 text-amber-600">
boş kaldı
</span>
) : null}
{p.kontenjan2025.toLocaleString("tr-TR")} kişi
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
<span className="inline-flex items-center gap-1">
<TabanTrendi p={p} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
disabled={listede || doldu}
onClick={(e) => onEkle(p, e.currentTarget)}
onClick={() => onEkle(p)}
title={
listede
? "Zaten listende"

View File

@@ -1,18 +1,13 @@
"use client";
// 24'lük listenin gövdesi — metin diyeti sonrası hali:
// AI cümleleri satırda değil, satıra dokununca açılan detayda yaşar.
// Satır tek bakışta taranır: sıra, program, üniversite·il, taban, trend, risk.
// Strateji şeridi paragraf yerine dilim sayıları + kısa uyarı rozetleri basar.
//
// kilitliBaslangic verilirse o indeksten sonraki satırlar artan blur ile
// kilitlenir ve üzerine kilitOverlay bindirilir (paketsiz önizleme).
// Kilitli satırların içeriği sunucuda zaten maskelidir (bkz. rapor-maske);
// buradaki blur yalnızca görsel dil.
// 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
// herkese görünür. AI gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
// ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
ChevronDown,
Lock,
MapPin,
Minus,
Rocket,
@@ -85,7 +80,7 @@ const TREND_GORUNUM: Record<
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
};
/** Son 4-5 yılın taban sıralaması mini çizgisi (detay panelinde). */
/** Son 5 yılın taban sıralaması mini çizgisi (detay panelinde). */
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
(n): n is { yil: number; sira: number } => n.sira != null,
@@ -114,7 +109,7 @@ function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
<div className="mt-2 flex items-center gap-2">
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-10 w-[150px]"
className="h-10 w-37.5"
role="img"
aria-label={`Taban sıralaması ${noktalar[0].yil}: ${noktalar[0].sira.toLocaleString("tr-TR")}, ${noktalar[noktalar.length - 1].yil}: ${noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}`}
>
@@ -177,22 +172,22 @@ export function RaporListesi({
}
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const acikTercihler = rapor.tercihler.slice(0, kilitIdx);
// Kilitli alan: tamamını basmaya gerek yok, blur duvarı kısa tutulur
const kilitliTercihler = rapor.tercihler.slice(kilitIdx, kilitIdx + 5);
const satir = (
t: RaporSonuc["tercihler"][number],
secenekler?: { blurPx?: number },
index: number,
) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi);
const trendG = trend ? TREND_GORUNUM[trend] : null;
const acikMi = acik.has(t.sira) && !secenekler?.blurPx;
const acikMi = acik.has(t.sira);
const sira2024 = p?.siraGecmisi?.[3] ?? null;
const sira2025 = p?.siraGecmisi?.[4] ?? null;
return (
<li
@@ -201,23 +196,20 @@ export function RaporListesi({
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
style={
secenekler?.blurPx
? { filter: `blur(${secenekler.blurPx}px)` }
: undefined
}
className={`rounded-xl border border-l-4 border-slate-200 bg-white ${stil.kenar}`}
className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-200 hover:border-slate-300 hover:shadow-xs ${
analizKilitli ? "border-slate-200" : `border-l-4 ${stil.kenar}`
}`}
>
<button
type="button"
onClick={() => tiklandi(t.sira)}
aria-expanded={acikMi}
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left"
className="grid min-h-16 w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 text-left xl:grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] xl:px-4"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira}
</span>
<span className="min-w-0 flex-1">
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">
{p?.isim ?? t.programId}
</span>
@@ -225,22 +217,47 @@ export function RaporListesi({
{p?.universite}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
<span className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-slate-500 xl:hidden">
<span>2024: {sira2024?.toLocaleString("tr-TR") ?? "—"}</span>
<span>2025: {sira2025?.toLocaleString("tr-TR") ?? "—"}</span>
{trendG ? <span className={trendG.renk}>{trendG.etiket}</span> : null}
</span>
</span>
<span className="hidden shrink-0 text-xs tabular-nums text-slate-500 sm:block">
{p?.efektifSira != null
? `~${p.efektifSira.toLocaleString("tr-TR")}.`
: ""}
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
{sira2024?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
{sira2025?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden items-center gap-1.5 text-xs xl:flex">
{trendG ? (
<>
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
<span className={trendG.renk}>
{trend === "zorlasiyor"
? "Zorlaşıyor"
: trend === "kolaylasiyor"
? "Rahatlıyor"
: "Stabil"}
</span>
</>
) : (
<span className="text-slate-400">Veri yok</span>
)}
</span>
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
{analizKilitli ? (
<>
<Lock className="size-3.5 text-slate-400" aria-hidden />
<span className="text-slate-500">Pakette</span>
</>
) : (
<>
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
<span>{RISK_ETIKET[risk]}</span>
</>
)}
</span>
{trendG ? (
<trendG.icon
className={`size-4 shrink-0 ${trendG.renk}`}
aria-label={trendG.etiket}
/>
) : null}
<span
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
aria-label={`${DILIM_ETIKET[t.dilim]} · ${RISK_ETIKET[risk]}`}
/>
<ChevronDown
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
@@ -248,43 +265,65 @@ export function RaporListesi({
</button>
{acikMi ? (
<div className="border-t border-slate-100 px-4 py-3">
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</span>
{analizKilitli ? (
<>
<Lock className="size-3.5" aria-hidden />
<span className="font-medium">Kişisel analiz pakette</span>
</>
) : (
<>
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</span>
</>
)}
{p?.unitur ? <span>· {p.unitur}</span> : null}
{p?.efektifSira != null ? (
<span className="sm:hidden">
· taban ~{p.efektifSira.toLocaleString("tr-TR")}.
</span>
) : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-lg bg-slate-50 p-3">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk notu
</dt>
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
4 yıllık trend
20212025 taban trendi
</dt>
<dd className="mt-1 text-slate-700">
{t.trendOzeti}
{p?.siraGecmisi ? (
<TrendCizgisi gecmis={p.siraGecmisi} />
) : null}
) : (
"Yeterli veri yok."
)}
</dd>
</div>
{analizKilitli ? (
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Lock className="size-3.5" aria-hidden />
AI karar desteği
</dt>
<dd className="mt-1 text-sm text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel yerleşme
riskini paketle gör.
</dd>
</div>
) : (
<>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve AI yorumu
</dt>
<dd className="mt-1 text-slate-700">
{t.riskNotu} {t.trendOzeti}
</dd>
</div>
</>
)}
</dl>
{onHaritadaGor && p?.il ? (
<button
@@ -304,27 +343,42 @@ export function RaporListesi({
return (
<div>
<p className="text-xs text-slate-500">
Satıra dokun; gerekçe, risk notu ve 4 yıllık trend ılsın.
</p>
<ol className="mt-2 space-y-2">{acikTercihler.map((t) => satir(t))}</ol>
{kilitliTercihler.length > 0 ? (
<div className="relative mt-2">
<ol
className="pointer-events-none max-h-[24rem] select-none space-y-2 overflow-hidden"
aria-hidden
>
{kilitliTercihler.map((t, j) =>
satir(t, { blurPx: Math.min(3 + j * 2, 12) }),
)}
</ol>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-slate-50/20 via-slate-50/75 to-slate-50 px-4">
{kilitOverlay}
</div>
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
<p className="mt-1 text-xs text-slate-500">
Satıra dokunarak 20212025 trendini inceleyebilirsin.
</p>
</div>
) : null}
{kilitIdx < rapor.tercihler.length ? (
<p className="text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null}
</div>
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
<span className="w-7">#</span>
<span>Program</span>
<span className="text-right">2024 taban</span>
<span className="text-right">2025 taban</span>
<span>Ham trend</span>
<span>AI risk</span>
<span className="w-4" />
</div>
<ol className="mt-2 flex flex-col gap-2">
{rapor.tercihler.map((t, index) => (
<Fragment key={t.sira}>
{index === kilitIdx && kilitOverlay ? (
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
{kilitOverlay}
</li>
) : null}
{satir(t, index)}
</Fragment>
))}
</ol>
</div>
);
}

View File

@@ -19,6 +19,7 @@ import { cities } from "turkey-map-react/lib/data";
import { Button } from "@/components/ui/button";
import {
HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
@@ -45,13 +46,11 @@ const RISK_RENK: Record<RiskSeviyesi, string> = {
riskli: "oklch(0.637 0.237 25.3)", // red-500
};
// SVG uzayı 0 0 1050 585 ama ülke çizimleri y≈144'te başlıyor; üstteki ölü
// bandı kırpıyoruz ki harita sayfada gereksiz boşluk bırakmasın.
const TAM_GORUNUM = {
x: 0,
y: 138,
y: HARITA_UST_KIRPMA,
w: HARITA_GENISLIK,
h: HARITA_YUKSEKLIK - 138,
h: HARITA_YUKSEKLIK - HARITA_UST_KIRPMA,
};
type ViewBox = typeof TAM_GORUNUM;