Update UI components and database schema
All checks were successful
Deploy / deploy (push) Successful in 2m38s
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:
@@ -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ç"
|
||||
|
||||
@@ -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ı aç
|
||||
</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ı aç
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 açı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'lük listene at.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold">
|
||||
Sıralamanla açı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'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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
2021–2025 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 açı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 2021–2025 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user