refactor: liste feature'ı — manuel-liste toptan taşındı (Faz 7)
- src/components/manuel-liste/ → src/features/liste/: kayitli-liste-actions → liste-actions, store → liste-store, dogrula → liste-dogrula, giris-url → liste-giris-url, oturum-sinyali → liste-oturum-sinyali, profil-kapisi-store → hooks/use-tercih-profili; 12 tsx → components/ - program-tablosu.tsx ve tercih-haritasi.tsx da features/liste/components'a geldi (rapor ve katalog cross-import ediyor) - app/layout.tsx'in 3 global mount'u (ListeCekmecesiLazy, KaydetBannerLazy, TercihProfiliKapisiLazy) yeni feature yollarından import ediliyor - liste-queries.ts /sonuc dönüşümüyle Faz 9'da gelecek - Davranış değişikliği yok; store/localStorage anahtarları aynı Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "./program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
profilDuzenlemeyiAc,
|
||||
sonucaGitIste,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "./program-netleri";
|
||||
import {
|
||||
Table,
|
||||
|
||||
@@ -2,8 +2,8 @@ import Link from "next/link";
|
||||
import { Coins } from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
||||
import { ListeSenkron } from "@/components/manuel-liste/liste-senkron";
|
||||
import { ListemButonu } from "@/features/liste/components/listem-butonu";
|
||||
import { ListeSenkron } from "@/features/liste/components/liste-senkron";
|
||||
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||
import { SignOutButton } from "./sign-out-button";
|
||||
|
||||
|
||||
87
src/features/liste/components/cihaz-ikonlari.tsx
Normal file
87
src/features/liste/components/cihaz-ikonlari.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
// "Listen yalnızca bu tarayıcıda" satırları için cihaz ikonları.
|
||||
//
|
||||
// - IsletimSistemiLogosu: kullanıcının şu an kullandığı işletim sisteminin
|
||||
// logosu (Apple / Windows / Android). UA yalnızca client'ta okunur; ilk
|
||||
// boyada nötr Monitor ikonu basılır ki SSR/hydration uyuşmazlığı olmasın.
|
||||
// iPad "Mac" olarak görünebilir ama ikisi de Apple logosuna düştüğü için
|
||||
// sorun değil; Linux ve bilinmeyenler Monitor'da kalır.
|
||||
// - CihazCifti: "her cihazdan eriş" vurgusu için telefon + bilgisayar ikonu.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Monitor, Smartphone } from "lucide-react";
|
||||
|
||||
type Isletim = "apple" | "windows" | "android" | null;
|
||||
|
||||
function isletimSistemi(): Isletim {
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPhone|iPad|iPod|Mac/i.test(ua)) return "apple";
|
||||
if (/Android/i.test(ua)) return "android";
|
||||
if (/Win/i.test(ua)) return "windows";
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M3 3h8.6v8.6H3zM12.4 3H21v8.6h-8.6zM3 12.4h8.6V21H3zM12.4 12.4H21V21h-8.6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AndroidLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M17.523 15.3414c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993.0001.5511-.4482.9997-.9993.9997m-11.046 0c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993 0 .5511-.4482.9997-.9993.9997m11.4045-6.02l1.9973-3.4592a.416.416 0 0 0-.1521-.5676.416.416 0 0 0-.5676.1521l-2.0223 3.503C15.5902 8.2439 13.8533 7.8508 12 7.8508s-3.5902.3931-5.1367 1.0989L4.841 5.4467a.4161.4161 0 0 0-.5677-.1521.4157.4157 0 0 0-.1521.5676l1.9973 3.4592C2.6889 11.1867.3432 14.6589 0 18.761h24c-.3435-4.1021-2.6892-7.5743-6.1185-9.4396" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IsletimSistemiLogosu({ className }: { className?: string }) {
|
||||
const [os, setOs] = useState<Isletim>(null);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOs(isletimSistemi());
|
||||
}, []);
|
||||
|
||||
if (os === "apple") return <AppleLogo className={className} />;
|
||||
if (os === "windows") return <WindowsLogo className={className} />;
|
||||
if (os === "android") return <AndroidLogo className={className} />;
|
||||
return <Monitor className={className} aria-hidden />;
|
||||
}
|
||||
|
||||
export function CihazCifti({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 ${className ?? ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
<Smartphone className="size-3.5" />
|
||||
<Monitor className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
11
src/features/liste/components/kaydet-banner-lazy.tsx
Normal file
11
src/features/liste/components/kaydet-banner-lazy.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline
|
||||
// bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni).
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const KaydetBannerLazy = dynamic(
|
||||
() => import("./kaydet-banner").then((m) => m.KaydetBanner),
|
||||
{ ssr: false },
|
||||
);
|
||||
110
src/features/liste/components/kaydet-banner.tsx
Normal file
110
src/features/liste/components/kaydet-banner.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
// C2 yumuşak banner: girişsiz kullanıcının manuel listesi 3. programa
|
||||
// ulaşınca bir kez görünen "listen yalnızca bu tarayıcıda" hatırlatması.
|
||||
// /sonuc (funnel panelleriyle yarışmasın) ve giriş gibi kromsuz sayfalar
|
||||
// hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage).
|
||||
// Exit-intent/agresif popup YOK (PRD kuralı).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { kromGizliMi } from "@/components/header-gate";
|
||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||
import { useManuelListe } from "../liste-store";
|
||||
import { useOturumDurumu } from "../liste-oturum-sinyali";
|
||||
import { useGirisUrl } from "../liste-giris-url";
|
||||
|
||||
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
|
||||
const ESIK = 3;
|
||||
|
||||
export function KaydetBanner() {
|
||||
const liste = useManuelListe();
|
||||
const girisli = useOturumDurumu();
|
||||
const pathname = usePathname();
|
||||
const girisUrl = useGirisUrl();
|
||||
|
||||
// SSR/ilk boyada gizli başla; kapatma durumu mount'ta okunur.
|
||||
const [kapali, setKapali] = useState(true);
|
||||
useEffect(() => {
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setKapali(Boolean(sessionStorage.getItem(KAPATMA_KEY)));
|
||||
} catch {
|
||||
setKapali(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const gorunumRef = useGorunumOlayi<HTMLDivElement>(
|
||||
"kaydet_banner_goruntulendi",
|
||||
);
|
||||
|
||||
if (girisli !== false || liste.length < ESIK || kapali) return null;
|
||||
|
||||
// /sonuc'ta gösterilmez: sayfanın kendi funnel panelleri var.
|
||||
if (pathname?.startsWith("/sonuc")) return null;
|
||||
|
||||
// Giriş gibi kromsuz sayfalarda gösterilmez: kullanıcı zaten giriş
|
||||
// akışındayken "Giriş yap" CTA'sı gereksiz gürültü olur.
|
||||
if (pathname && kromGizliMi(pathname)) return null;
|
||||
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||
<div
|
||||
ref={gorunumRef}
|
||||
className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl"
|
||||
>
|
||||
{/* Kullanıcının o anki işletim sisteminin logosu: "bu tarayıcıda"
|
||||
mesajını somutlaştırır */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-700">
|
||||
<IsletimSistemiLogosu className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
Listen yalnızca bu tarayıcıda
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Giriş yap; {liste.length} seçimin hesabında saklansın, her
|
||||
cihazdan aç{" "}
|
||||
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() =>
|
||||
olay("giris_cta_tiklandi", { kaynak: "liste_kaydet_banner" })
|
||||
}
|
||||
>
|
||||
Giriş yap, kaydet
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={() => {
|
||||
setKapali(true);
|
||||
try {
|
||||
sessionStorage.setItem(KAPATMA_KEY, "1");
|
||||
} catch {}
|
||||
}}
|
||||
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
12
src/features/liste/components/liste-cekmecesi-lazy.tsx
Normal file
12
src/features/liste/components/liste-cekmecesi-lazy.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
// Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline
|
||||
// bundle'a girmemesi için ssr'siz dynamic import (layout server component
|
||||
// olduğundan bu sarmalayıcı gerekli).
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const ListeCekmecesiLazy = dynamic(
|
||||
() => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi),
|
||||
{ ssr: false },
|
||||
);
|
||||
417
src/features/liste/components/liste-cekmecesi.tsx
Normal file
417
src/features/liste/components/liste-cekmecesi.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
// Manuel 24'lük listenin alt çekmecesi (bottom drawer). Layout'ta bir kez
|
||||
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
|
||||
//
|
||||
// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır
|
||||
// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması
|
||||
// da kapatır.
|
||||
//
|
||||
// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
|
||||
// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları
|
||||
// sadeleşir; X butonu her durumda kalır.
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PixelField } from "@/components/pixel-decor";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
ArrowRight,
|
||||
GripVertical,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
Reorder,
|
||||
useDragControls,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
type PanInfo,
|
||||
} from "motion/react";
|
||||
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||
import { sonucaGitIste } from "../hooks/use-tercih-profili";
|
||||
import { useOturumDurumu } from "../liste-oturum-sinyali";
|
||||
import { useGirisUrl } from "../liste-giris-url";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceAc,
|
||||
cekmeceKapat,
|
||||
cikar,
|
||||
temizle,
|
||||
useCekmeceAcik,
|
||||
useManuelListe,
|
||||
yenidenSirala,
|
||||
type ManuelTercih,
|
||||
} from "../liste-store";
|
||||
|
||||
// Slug kuralları katalog.ts'tekiyle aynı (bolumBazAdi/uniAdiNormalize +
|
||||
// turkishSlugify); katalog haritaları DB'deki tüm isim/universite
|
||||
// değerlerinden kurulduğu için bu linkler var olan sayfalara çıkar. Harita
|
||||
// verisini client bundle'a çekmemek için burada tekrarlanır (bkz.
|
||||
// program-tablosu'ndaki uniSayfaSlug).
|
||||
function bolumHref(isim: string): string {
|
||||
return `/bolum/${turkishSlugify(bolumBazAdi(isim))}`;
|
||||
}
|
||||
function uniHref(ad: string): string {
|
||||
return `/universite/${turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim())}`;
|
||||
}
|
||||
|
||||
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
|
||||
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
|
||||
guvenli: "bg-emerald-500",
|
||||
"az-riskli": "bg-amber-500",
|
||||
riskli: "bg-red-500",
|
||||
};
|
||||
|
||||
export function ListeCekmecesi() {
|
||||
const router = useRouter();
|
||||
const acik = useCekmeceAcik();
|
||||
const liste = useManuelListe();
|
||||
const girisli = useOturumDurumu();
|
||||
const girisUrl = useGirisUrl();
|
||||
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
|
||||
// drawer içinde erken ölçüm satırları kaydırıyordu
|
||||
const [oturdu, setOturdu] = useState(false);
|
||||
|
||||
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
|
||||
|
||||
function programaEkleGit() {
|
||||
cekmeceKapat();
|
||||
const { href } = sonucaGitIste();
|
||||
if (href) router.push(href);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={acik}
|
||||
onOpenChange={(v) => {
|
||||
if (v) cekmeceAc();
|
||||
else {
|
||||
setOturdu(false);
|
||||
cekmeceKapat();
|
||||
}
|
||||
}}
|
||||
handleOnly
|
||||
onAnimationEnd={(open) => setOturdu(open)}
|
||||
>
|
||||
<DrawerContent
|
||||
data-lenis-prevent
|
||||
className="min-h-0 max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||||
>
|
||||
<DrawerHeader className="border-b border-slate-100 text-left md:text-left">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DrawerTitle className="flex items-center gap-2 font-heading text-lg font-bold">
|
||||
<ListChecks className="size-5 text-primary" aria-hidden />
|
||||
Kendi Listem
|
||||
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
|
||||
{liste.length}/{MANUEL_LISTE_MAX}
|
||||
</span>
|
||||
</DrawerTitle>
|
||||
<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}
|
||||
<DrawerClose 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>
|
||||
</DrawerClose>
|
||||
</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>
|
||||
<ul
|
||||
className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500"
|
||||
aria-label="Yerleşme riski renkleri"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["guvenli", "yeşil güvenli"],
|
||||
["az-riskli", "turuncu az riskli"],
|
||||
["riskli", "kırmızı riskli"],
|
||||
] as const
|
||||
).map(([seviye, aciklama]) => (
|
||||
<li key={seviye} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${RISK_NOKTA[seviye]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{aciklama}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DrawerHeader>
|
||||
|
||||
<div
|
||||
data-lenis-prevent
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-4"
|
||||
>
|
||||
{liste.length === 0 ? (
|
||||
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
|
||||
<ListChecks
|
||||
className="pointer-events-none absolute top-1/2 left-1/2 size-40 -translate-x-1/2 -translate-y-[55%] text-slate-300/70 select-none [mask-image:linear-gradient(to_bottom,black_20%,transparent_85%)] [-webkit-mask-image:linear-gradient(to_bottom,black_20%,transparent_85%)]"
|
||||
strokeWidth={1.25}
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="relative font-heading text-sm font-bold text-slate-700">
|
||||
Henüz program seçmedin
|
||||
</p>
|
||||
<p className="relative text-xs leading-relaxed text-slate-500">
|
||||
Sonuç sayfasından program ekleyerek listeni doldurmaya başla.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={liste}
|
||||
onReorder={yenidenSirala}
|
||||
layoutScroll
|
||||
as="ol"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{liste.map((t, i) => (
|
||||
<CekmeceSatiri
|
||||
key={t.id}
|
||||
tercih={t}
|
||||
sira={i + 1}
|
||||
projeksiyonAcik={oturdu}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{liste.length < MANUEL_LISTE_MAX ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={programaEkleGit}
|
||||
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-full border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
|
||||
liste.length > 0 ? "mt-2" : "mt-1"
|
||||
}`}
|
||||
>
|
||||
<Plus className="size-5" aria-hidden />
|
||||
<span className="sr-only">Sonuç sayfasından program ekle</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="border-t border-slate-100 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
|
||||
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
|
||||
{girisli === false ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left">
|
||||
<p className="min-w-0 text-xs leading-relaxed text-slate-600">
|
||||
{/* O anki işletim sisteminin logosu — "bu tarayıcıda"yı somutlaştırır */}
|
||||
<IsletimSistemiLogosu className="mr-1.5 inline size-3.5 align-[-3px] text-slate-700" />
|
||||
<span className="font-semibold text-slate-800">
|
||||
Listen yalnızca bu tarayıcıda.
|
||||
</span>{" "}
|
||||
Giriş yap; her cihazdan eriş{" "}
|
||||
<CihazCifti className="align-[-3px] text-slate-400" />
|
||||
</p>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => {
|
||||
cekmeceKapat();
|
||||
olay("giris_cta_tiklandi", { kaynak: "liste_cekmecesi" });
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-primary transition-colors duration-200 hover:text-primary/80"
|
||||
>
|
||||
Giriş yap, kaydet
|
||||
<ArrowRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary
|
||||
zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */}
|
||||
<div className="relative isolate flex items-center justify-between gap-4 overflow-hidden rounded-2xl bg-primary px-5 py-5 text-left text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-6 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={8} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-2xl font-bold">
|
||||
Risk analizini biz yaptık
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm leading-relaxed text-white">
|
||||
Her seçimin yerleşme riski hazır — son kararı sen ver.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/listem" onClick={cekmeceKapat}>
|
||||
Listemde aç
|
||||
<ArrowRight data-icon="inline-end" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
|
||||
const SIL_ESIK = 90;
|
||||
|
||||
function CekmeceSatiri({
|
||||
tercih,
|
||||
sira,
|
||||
projeksiyonAcik,
|
||||
}: {
|
||||
tercih: ManuelTercih;
|
||||
sira: number;
|
||||
projeksiyonAcik: boolean;
|
||||
}) {
|
||||
const siraKontrol = useDragControls();
|
||||
|
||||
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
|
||||
const x = useMotionValue(0);
|
||||
const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
|
||||
|
||||
const kaydirinca = (_: unknown, info: PanInfo) => {
|
||||
if (
|
||||
info.offset.x > SIL_ESIK ||
|
||||
(info.offset.x > 32 && info.velocity.x > 500)
|
||||
) {
|
||||
cikar(tercih.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={tercih}
|
||||
as="li"
|
||||
layout={projeksiyonAcik ? "position" : undefined}
|
||||
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
|
||||
dragListener={false}
|
||||
dragControls={siraKontrol}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
|
||||
whileDrag={{ scale: 1.03, zIndex: 10 }}
|
||||
className="relative mb-2 select-none last:mb-0"
|
||||
>
|
||||
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
|
||||
<motion.div
|
||||
style={{ opacity: silGorunur }}
|
||||
className="absolute inset-0 flex items-center rounded-xl bg-red-500 pl-4"
|
||||
aria-hidden
|
||||
>
|
||||
<Trash2 className="size-4 text-white" />
|
||||
</motion.div>
|
||||
|
||||
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
|
||||
<motion.div
|
||||
drag="x"
|
||||
style={{ x }}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={{ left: 0, right: 0.7 }}
|
||||
dragSnapToOrigin
|
||||
onDragEnd={kaydirinca}
|
||||
className="relative flex items-center gap-2.5 rounded-xl border border-slate-200 bg-white px-3 py-2.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
|
||||
siraKontrol.start(e);
|
||||
}}
|
||||
className="-m-1 shrink-0 cursor-grab touch-none rounded p-1 text-slate-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-4" aria-hidden />
|
||||
<span className="sr-only">{tercih.isim} sırasını değiştir</span>
|
||||
</button>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-[11px] font-bold tabular-nums">
|
||||
{sira}
|
||||
</span>
|
||||
<UniLogo ad={tercih.universite} boy="xs" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={bolumHref(tercih.isim)}
|
||||
onClick={cekmeceKapat}
|
||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{tercih.isim}
|
||||
</Link>
|
||||
<span className="block truncate text-xs text-slate-500">
|
||||
<Link
|
||||
href={uniHref(tercih.universite)}
|
||||
onClick={cekmeceKapat}
|
||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||
title={`${tercih.universite} taban puanları sayfası`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
{tercih.universite}
|
||||
</Link>
|
||||
{tercih.il ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{tercih.efektifSira != null ? (
|
||||
<span className="shrink-0 text-xs tabular-nums text-slate-500">
|
||||
~{tercih.efektifSira.toLocaleString("tr-TR")}.
|
||||
</span>
|
||||
) : null}
|
||||
{tercih.risk ? (
|
||||
<span
|
||||
className={`size-2.5 shrink-0 rounded-full ${RISK_NOKTA[tercih.risk]}`}
|
||||
aria-label={RISK_ETIKET[tercih.risk]}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cikar(tercih.id)}
|
||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||
className="shrink-0 cursor-pointer rounded-full p-1.5 text-slate-300 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">{tercih.isim} listeden çıkar</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
97
src/features/liste/components/liste-senkron.tsx
Normal file
97
src/features/liste/components/liste-senkron.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
// Manuel liste + sihirbaz profilinin sunucu senkronu (İP-C). UserNav'dan
|
||||
// (sunucu bileşeni) userId prop'uyla monte edilir — RybbitIdentify ile aynı
|
||||
// kanonik desen. Hiçbir şey render etmez.
|
||||
//
|
||||
// - Mount: oturum sinyali yayılır (çekmece/banner girişsizliği buradan öğrenir).
|
||||
// - Girişte: oturum başına 1 kez merge (çakışmada lokal kazanır); dönen
|
||||
// kanonik liste lokale birleşir, profil lokalde yoksa tercihProfiliYaz ile
|
||||
// iner (PROFIL_DEGISTI_EVENT otomatik yayılır, profil kapısı tazelenir).
|
||||
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { PROFIL_DEGISTI_EVENT } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku, tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import { listeOku, sunucuylaBirlestir, useManuelListe } from "../liste-store";
|
||||
import { oturumDurumuBildir } from "../liste-oturum-sinyali";
|
||||
import { listeSenkronla, listeyiKaydet } from "../liste-actions";
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
|
||||
export function ListeSenkron({ userId }: { userId: string | null }) {
|
||||
const liste = useManuelListe();
|
||||
const mergeTamam = useRef(false);
|
||||
const sonGonderilen = useRef<string | null>(null);
|
||||
const zamanlayici = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
oturumDurumuBildir(Boolean(userId));
|
||||
}, [userId]);
|
||||
|
||||
// Giriş anı merge'i — sekme oturumu başına bir kez (idempotent olsa da
|
||||
// her gezinmede sunucuya gitmesin diye sessionStorage ile tekilleştirilir).
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const anahtar = `kolaytercih.liste-senkron.${userId}`;
|
||||
try {
|
||||
if (sessionStorage.getItem(anahtar)) {
|
||||
mergeTamam.current = true;
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
let iptal = false;
|
||||
void (async () => {
|
||||
const sonuc = await listeSenkronla({
|
||||
items: listeOku(),
|
||||
profil: tercihProfiliOku(),
|
||||
});
|
||||
if (iptal || !sonuc.ok) return;
|
||||
sunucuylaBirlestir(sonuc.items);
|
||||
if (sonuc.profil && !tercihProfiliOku()) {
|
||||
tercihProfiliYaz(sonuc.profil);
|
||||
}
|
||||
// Merge sonrası anlık durumu "gönderildi" say — debounce aynı veriyi
|
||||
// hemen geri yazmasın.
|
||||
sonGonderilen.current = JSON.stringify({
|
||||
items: listeOku(),
|
||||
profil: tercihProfiliOku(),
|
||||
});
|
||||
mergeTamam.current = true;
|
||||
try {
|
||||
sessionStorage.setItem(anahtar, "1");
|
||||
} catch {}
|
||||
})();
|
||||
return () => {
|
||||
iptal = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
// Girişliyken süregelen senkron: liste (store aboneliği) ve profil
|
||||
// (PROFIL_DEGISTI_EVENT) değişimlerini debounce ile sunucuya it.
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
const planla = () => {
|
||||
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
|
||||
zamanlayici.current = window.setTimeout(() => {
|
||||
if (!mergeTamam.current) return;
|
||||
const items = listeOku();
|
||||
const profil = tercihProfiliOku();
|
||||
const imza = JSON.stringify({ items, profil });
|
||||
if (imza === sonGonderilen.current) return;
|
||||
sonGonderilen.current = imza;
|
||||
void listeyiKaydet({ items, profil });
|
||||
}, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
planla();
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, planla);
|
||||
return () => {
|
||||
window.removeEventListener(PROFIL_DEGISTI_EVENT, planla);
|
||||
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
|
||||
};
|
||||
}, [userId, liste]);
|
||||
|
||||
return null;
|
||||
}
|
||||
51
src/features/liste/components/listem-butonu.tsx
Normal file
51
src/features/liste/components/listem-butonu.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
// Navbar'daki "Seçtiklerim" butonu: elle kurulan aday listesinin sayacını
|
||||
// taşır, tıklanınca çekmeceyi açar/kapatır. ("Listem" adı Yapay Zeka raporunun
|
||||
// yaşadığı /listem sayfasına aittir — isim çakışmasın.)
|
||||
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceDegistir,
|
||||
useCekmeceAcik,
|
||||
useManuelListe,
|
||||
} from "../liste-store";
|
||||
|
||||
export function ListemButonu() {
|
||||
const liste = useManuelListe();
|
||||
const acik = useCekmeceAcik();
|
||||
const azMotion = useReducedMotion();
|
||||
|
||||
// Liste boşken buton gürültü: tabloya (+) ulaşmamış ziyaretçiye "0/24"
|
||||
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
|
||||
if (liste.length === 0) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cekmeceDegistir}
|
||||
aria-expanded={acik}
|
||||
aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`}
|
||||
title="Tablodan seçtiğin programlar"
|
||||
className="relative inline-flex h-9 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97] sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
|
||||
>
|
||||
<ListChecks className="size-3.5 text-primary sm:size-4" aria-hidden />
|
||||
{/* lg–xl: ortadaki nav ile yer dar — metin gizli, ikon+rozet kalır */}
|
||||
<span className="hidden sm:inline lg:hidden xl:inline">Kendi Listem</span>
|
||||
{liste.length > 0 ? (
|
||||
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
|
||||
<motion.span
|
||||
key={liste.length}
|
||||
initial={azMotion ? false : { scale: 0.6 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0.45 }}
|
||||
className="flex size-5 items-center justify-center rounded-full bg-orange-500 font-heading text-[11px] font-bold tabular-nums text-white"
|
||||
>
|
||||
{liste.length}
|
||||
</motion.span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
76
src/features/liste/components/manuel-harita.tsx
Normal file
76
src/features/liste/components/manuel-harita.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
// Manuel 24'lük listenin coğrafyası: tablodan "+" ile eklenen her programın
|
||||
// üniversitesi gerçek kampüs konumunda risk renkli pinlenir. Liste store'una
|
||||
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/features/liste/components/tercih-haritasi";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
import { useManuelListe } from "../liste-store";
|
||||
|
||||
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
|
||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 1,
|
||||
riskli: 2,
|
||||
};
|
||||
|
||||
export function ManuelHarita() {
|
||||
const liste = useManuelListe();
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
|
||||
const { pinler, haritaDisi } = useMemo(() => {
|
||||
const gruplar = new Map<string, HaritaPin>();
|
||||
let haritaDisi = 0;
|
||||
|
||||
liste.forEach((t, i) => {
|
||||
const konum = uniKonum(t.universite, t.il) ?? ilKonum(t.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const risk = t.risk ?? "az-riskli";
|
||||
const key = `${uniAdiNormalize(t.universite)}|${t.il ?? ""}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(i + 1);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: t.universite,
|
||||
il: t.il,
|
||||
x: konum.x,
|
||||
y: konum.y,
|
||||
tercihSiralari: [i + 1],
|
||||
risk,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { pinler: [...gruplar.values()], haritaDisi };
|
||||
}, [liste]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TercihHaritasi
|
||||
pinler={pinler}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
/>
|
||||
{haritaDisi > 0 ? (
|
||||
<p className="mt-1 px-1 text-xs text-slate-500">
|
||||
{haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
{liste.length === 0 ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500">
|
||||
Tablodan <span aria-hidden>+</span> ile ekledikçe üniversiteler
|
||||
haritada belirir.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/features/liste/components/program-ekle-butonu.tsx
Normal file
89
src/features/liste/components/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 "@/types/yokatlas";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
listedeMi,
|
||||
MANUEL_LISTE_MAX,
|
||||
useManuelListe,
|
||||
type ManuelKaynak,
|
||||
} from "../liste-store";
|
||||
import { programEkleIste } from "../hooks/use-tercih-profili";
|
||||
|
||||
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.doldu) {
|
||||
toast.info(`Listen ${MANUEL_LISTE_MAX} tercihe ulaştı.`);
|
||||
} else if (sonuc.zatenVar) {
|
||||
toast.info("Bu program zaten seçtiklerinde.");
|
||||
} else {
|
||||
olay("program_eklendi", { kaynak });
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
342
src/features/liste/components/program-tablosu.tsx
Normal file
342
src/features/liste/components/program-tablosu.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
// Sonuç sayfasının Yapay Zeka'sız ham listesi: yalnızca başarı sıralamasıyla
|
||||
// yokatlas'tan gelen programlar, dilim sekmeleri halinde ve yakından uzağa
|
||||
// sayfalanır ("daha fazla göster" /api/programlar'dan akıtır). Her satırın "+"
|
||||
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronDown,
|
||||
Rocket,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/types/yokatlas";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
|
||||
const DILIMLER: {
|
||||
key: DilimKey;
|
||||
etiket: string;
|
||||
aciklama: string;
|
||||
icon: typeof Rocket;
|
||||
renk: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "hayal",
|
||||
etiket: "Hayal",
|
||||
aciklama: "Tabanı sıralamanın üstünde — şansını denediğin satırlar.",
|
||||
icon: Rocket,
|
||||
renk: "text-red-500",
|
||||
},
|
||||
{
|
||||
key: "dengeli",
|
||||
etiket: "Dengeli",
|
||||
aciklama: "Tabanı sıralamana denk — listenin bel kemiği.",
|
||||
icon: Scale,
|
||||
renk: "text-amber-500",
|
||||
},
|
||||
{
|
||||
key: "garanti",
|
||||
etiket: "Garanti",
|
||||
aciklama: "Tabanı belirgin şekilde altında — güvenli bölge.",
|
||||
icon: ShieldCheck,
|
||||
renk: "text-emerald-600",
|
||||
},
|
||||
];
|
||||
|
||||
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
|
||||
guvenli: "bg-emerald-500",
|
||||
"az-riskli": "bg-amber-500",
|
||||
riskli: "bg-red-500",
|
||||
};
|
||||
|
||||
const BOS_EKSTRA: Record<DilimKey, Program[]> = {
|
||||
hayal: [],
|
||||
dengeli: [],
|
||||
garanti: [],
|
||||
};
|
||||
|
||||
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
|
||||
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
|
||||
function varsayilanDilim(sonuclar: RankResults): DilimKey {
|
||||
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
|
||||
? "garanti"
|
||||
: "dengeli";
|
||||
}
|
||||
|
||||
export function ProgramTablosu({
|
||||
sonuclar,
|
||||
adaySira,
|
||||
tur,
|
||||
}: {
|
||||
sonuclar: RankResults;
|
||||
adaySira: number;
|
||||
tur: PuanTuruKey;
|
||||
}) {
|
||||
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
|
||||
varsayilanDilim(sonuclar),
|
||||
);
|
||||
|
||||
// "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)
|
||||
// render sırasında sıfırlanır ki eski türün satırları yenisine karışmasın.
|
||||
const [ekstra, setEkstra] = useState(BOS_EKSTRA);
|
||||
const [yuklenen, setYuklenen] = useState<DilimKey | null>(null);
|
||||
const veriAnahtari = `${adaySira}-${tur}`;
|
||||
const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari);
|
||||
if (veriAnahtari !== oncekiAnahtar) {
|
||||
setOncekiAnahtar(veriAnahtari);
|
||||
setEkstra(BOS_EKSTRA);
|
||||
// Yeni sıra/türde dengeli+hayal boş kaldıysa boş sekmede bırakma
|
||||
if (varsayilanDilim(sonuclar) === "garanti" && aktifDilim !== "garanti") {
|
||||
setAktifDilim("garanti");
|
||||
}
|
||||
}
|
||||
|
||||
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
|
||||
setYuklenen(dilim);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const veri = (await res.json()) as { programlar: Program[] };
|
||||
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
|
||||
setEkstra((mevcut) => {
|
||||
const eldekiler = new Set(
|
||||
[...sonuclar[dilim], ...mevcut[dilim]].map((p) => p.id),
|
||||
);
|
||||
return {
|
||||
...mevcut,
|
||||
[dilim]: [
|
||||
...mevcut[dilim],
|
||||
...veri.programlar.filter((p) => !eldekiler.has(p.id)),
|
||||
],
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
} finally {
|
||||
setYuklenen(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
|
||||
<Tabs
|
||||
value={aktifDilim}
|
||||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||||
>
|
||||
{/* Dilim sekmeleri tablonun hemen üstünde; puan türü burada değil,
|
||||
navbar'daki sıralama rozetinden değiştirilir. */}
|
||||
<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.toplam[d.key].toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{DILIMLER.map((d) => {
|
||||
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]];
|
||||
const toplam = sonuclar.toplam[d.key];
|
||||
return (
|
||||
<TabsContent key={d.key} value={d.key}>
|
||||
<p className="mt-2 text-xs text-slate-500">{d.aciklama}</p>
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
{satirlar.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
Bu dilimde filtrene uyan program bulunamadı.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<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-24 text-right">
|
||||
<span className="sr-only">Netler ve listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{satirlar.map((p) => (
|
||||
<ProgramSatiri
|
||||
key={p.id}
|
||||
program={p}
|
||||
adaySira={adaySira}
|
||||
tur={tur}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
{satirlar.length < toplam ? (
|
||||
<div className="mt-3 flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={yuklenen === d.key}
|
||||
onClick={() => dahaFazlaYukle(d.key, satirlar.length)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors duration-150 hover:border-slate-300 hover:bg-slate-50 disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
{yuklenen === d.key ? "Yükleniyor…" : "Daha fazla göster"}
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-slate-400">
|
||||
{satirlar.length.toLocaleString("tr-TR")} /{" "}
|
||||
{toplam.toLocaleString("tr-TR")} program
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgramSatiri({
|
||||
program: p,
|
||||
adaySira,
|
||||
tur,
|
||||
}: {
|
||||
program: Program;
|
||||
adaySira: number;
|
||||
tur: PuanTuruKey;
|
||||
}) {
|
||||
const taban = programEtkinSira(p);
|
||||
const risk = riskHesapla(taban, adaySira);
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="pl-4 pr-0">
|
||||
{risk ? (
|
||||
<span
|
||||
className={`block size-2.5 rounded-full ${RISK_NOKTA[risk]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<UniLogo ad={p.universite} boy="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
|
||||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={`${p.isim} bölüm sayfası`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{p.isim}
|
||||
</Link>
|
||||
<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">
|
||||
<Link
|
||||
href={`/universite/${uniSayfaSlug(p.universite)}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
title={`${p.universite} taban puanları sayfası`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{p.universite}
|
||||
</Link>
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
{p.sure ? (
|
||||
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</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 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">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ProgramTabanTrendi program={p} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgramNetleriButonu
|
||||
programId={p.id}
|
||||
isim={p.isim}
|
||||
universite={p.universite}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="sonuc"
|
||||
adaySira={adaySira}
|
||||
adayTur={tur}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
305
src/features/liste/components/secimlerim-paneli.tsx
Normal file
305
src/features/liste/components/secimlerim-paneli.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
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 { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
tercihDegerlendir,
|
||||
tercihListesiOzeti,
|
||||
} from "@/lib/tercih-degerlendirme";
|
||||
import {
|
||||
cikar,
|
||||
MANUEL_LISTE_MAX,
|
||||
useManuelListe,
|
||||
type ManuelTercih,
|
||||
} from "../liste-store";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
sonucaGitIste,
|
||||
useTercihProfili,
|
||||
} from "../hooks/use-tercih-profili";
|
||||
|
||||
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 router = useRouter();
|
||||
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;
|
||||
|
||||
function sonucaGit() {
|
||||
const { href } = sonucaGitIste();
|
||||
if (href) router.push(href);
|
||||
}
|
||||
|
||||
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>
|
||||
{profil ? (
|
||||
<Button
|
||||
asChild
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href={sonucHref(profil)}>Sonuçlara git</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={sonucaGit}
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
Sıralamanı gir
|
||||
</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 />
|
||||
Kendi Listem
|
||||
<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>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={sonucaGit}
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
Sıralamanı gir
|
||||
</Button>
|
||||
)}
|
||||
</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="flex min-w-0 items-center gap-2.5">
|
||||
<UniLogo ad={tercih.universite} boy="sm" />
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
477
src/features/liste/components/tercih-haritasi.tsx
Normal file
477
src/features/liste/components/tercih-haritasi.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
"use client";
|
||||
|
||||
// Sonuç sayfasının kahraman öğesi: 24 tercihin Türkiye haritası üzerinde
|
||||
// risk renkli pinlerle gösterimi. Ülke seviyesinde il başına tek toplu pin;
|
||||
// ile tıklayınca viewBox o ilin sınırlarına animasyonla akar, il büyür ve
|
||||
// üniversite pinleri gerçek kampüs konumlarında adlarıyla görünür.
|
||||
// Kilitli (paketsiz) tercihler il merkezinde adsız "?" pini olarak çizilir —
|
||||
// üniversite adı client'a hiç inmediği için paywall haritadan delinemez.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Lock } from "lucide-react";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { uniLogoYoluFromAd } from "@/components/uni-logo";
|
||||
import {
|
||||
HARITA_GENISLIK,
|
||||
HARITA_UST_KIRPMA,
|
||||
HARITA_YUKSEKLIK,
|
||||
normalizeIlAdi,
|
||||
} from "@/lib/harita";
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
export type HaritaPin = {
|
||||
/** Üniversite bazlı benzersiz anahtar (kilitlilerde sentetik) */
|
||||
id: string;
|
||||
/** Görünen ad — kilitli pinlerde boş bırakılır */
|
||||
universite: string;
|
||||
/** DB biçiminde il adı (büyük harf); harita dışıysa null */
|
||||
il: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Bu üniversitedeki tercihlerin liste sıraları (1-24) */
|
||||
tercihSiralari: number[];
|
||||
risk: RiskSeviyesi;
|
||||
kilitli?: boolean;
|
||||
};
|
||||
|
||||
const RISK_RENK: Record<RiskSeviyesi, string> = {
|
||||
guvenli: "oklch(0.696 0.17 162.5)", // emerald-500
|
||||
"az-riskli": "oklch(0.769 0.188 70.1)", // amber-500
|
||||
riskli: "oklch(0.637 0.237 25.3)", // red-500
|
||||
};
|
||||
|
||||
const TAM_GORUNUM = {
|
||||
x: 0,
|
||||
y: HARITA_UST_KIRPMA,
|
||||
w: HARITA_GENISLIK,
|
||||
h: HARITA_YUKSEKLIK - HARITA_UST_KIRPMA,
|
||||
};
|
||||
|
||||
type ViewBox = typeof TAM_GORUNUM;
|
||||
|
||||
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
|
||||
function kisaUniAdi(ad: string): string {
|
||||
const kisa = ad
|
||||
.replace(/\s+(ÜNİVERSİTESİ|ENSTİTÜSÜ|MESLEK YÜKSEKOKULU)$/i, "")
|
||||
.trim();
|
||||
return kisa
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.split(" ")
|
||||
.map((k) =>
|
||||
k.length > 2 || k === "29"
|
||||
? k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1)
|
||||
: k,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** İldeki pinlerin çoğunluk riski (eşitlikte güvenli taraf). */
|
||||
function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi {
|
||||
const sayac: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 0,
|
||||
riskli: 0,
|
||||
};
|
||||
for (const p of pinler) sayac[p.risk] += p.tercihSiralari.length;
|
||||
return (["guvenli", "az-riskli", "riskli"] as const).reduce((a, b) =>
|
||||
sayac[b] > sayac[a] ? b : a,
|
||||
);
|
||||
}
|
||||
|
||||
/** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */
|
||||
function cakismaAc(pinler: HaritaPin[]): HaritaPin[] {
|
||||
const gruplar = new Map<string, HaritaPin[]>();
|
||||
for (const p of pinler) {
|
||||
const key = `${Math.round(p.x)}:${Math.round(p.y)}`;
|
||||
const grup = gruplar.get(key);
|
||||
if (grup) grup.push(p);
|
||||
else gruplar.set(key, [p]);
|
||||
}
|
||||
const sonuc: HaritaPin[] = [];
|
||||
for (const grup of gruplar.values()) {
|
||||
if (grup.length === 1) {
|
||||
sonuc.push(grup[0]);
|
||||
continue;
|
||||
}
|
||||
grup.forEach((p, i) => {
|
||||
const aci = (2 * Math.PI * i) / grup.length;
|
||||
sonuc.push({ ...p, x: p.x + Math.cos(aci) * 5, y: p.y + Math.sin(aci) * 5 });
|
||||
});
|
||||
}
|
||||
return sonuc;
|
||||
}
|
||||
|
||||
export function TercihHaritasi({
|
||||
pinler,
|
||||
seciliIl,
|
||||
onSeciliIlDegisti,
|
||||
onTercihSec,
|
||||
}: {
|
||||
pinler: HaritaPin[];
|
||||
/** Zoom'lanacak il (DB biçimi) — liste tarafından da kontrol edilebilir */
|
||||
seciliIl: string | null;
|
||||
onSeciliIlDegisti: (il: string | null) => void;
|
||||
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
|
||||
onTercihSec?: (sira: number) => void;
|
||||
}) {
|
||||
const pathRefs = useRef(new Map<string, SVGPathElement>());
|
||||
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
|
||||
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
|
||||
const animRef = useRef(0);
|
||||
|
||||
const animateTo = useCallback((hedef: ViewBox) => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
const kaynak = { ...vbRef.current };
|
||||
const t0 = performance.now();
|
||||
const SURE = 380;
|
||||
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
const adim = (now: number) => {
|
||||
const t = Math.min(1, (now - t0) / SURE);
|
||||
const k = ease(t);
|
||||
const sonraki = {
|
||||
x: kaynak.x + (hedef.x - kaynak.x) * k,
|
||||
y: kaynak.y + (hedef.y - kaynak.y) * k,
|
||||
w: kaynak.w + (hedef.w - kaynak.w) * k,
|
||||
h: kaynak.h + (hedef.h - kaynak.h) * k,
|
||||
};
|
||||
vbRef.current = sonraki;
|
||||
setVb(sonraki);
|
||||
if (t < 1) animRef.current = requestAnimationFrame(adim);
|
||||
};
|
||||
animRef.current = requestAnimationFrame(adim);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => cancelAnimationFrame(animRef.current), []);
|
||||
|
||||
// seciliIl değişince ilgili ilin bbox'ına (veya tam görünüme) ak
|
||||
useEffect(() => {
|
||||
if (!seciliIl) {
|
||||
animateTo(TAM_GORUNUM);
|
||||
return;
|
||||
}
|
||||
const path = pathRefs.current.get(seciliIl);
|
||||
if (!path) return;
|
||||
const b = path.getBBox();
|
||||
const pad = Math.max(b.width, b.height) * 0.22;
|
||||
let x = b.x - pad;
|
||||
let y = b.y - pad;
|
||||
let w = b.width + pad * 2;
|
||||
let h = b.height + pad * 2;
|
||||
// viewBox oranını koru ki il yamulmadan büyüsün ve harita yüksekliği
|
||||
// zoom'da değişmesin (tam görünümle aynı oran)
|
||||
const oran = TAM_GORUNUM.w / TAM_GORUNUM.h;
|
||||
if (w / h > oran) {
|
||||
const yeniH = w / oran;
|
||||
y -= (yeniH - h) / 2;
|
||||
h = yeniH;
|
||||
} else {
|
||||
const yeniW = h * oran;
|
||||
x -= (yeniW - w) / 2;
|
||||
w = yeniW;
|
||||
}
|
||||
animateTo({ x, y, w, h });
|
||||
}, [seciliIl, animateTo]);
|
||||
|
||||
const ilPinleri = useMemo(() => {
|
||||
const gruplar = new Map<string, HaritaPin[]>();
|
||||
for (const p of pinler) {
|
||||
if (!p.il) continue;
|
||||
const grup = gruplar.get(p.il);
|
||||
if (grup) grup.push(p);
|
||||
else gruplar.set(p.il, [p]);
|
||||
}
|
||||
return gruplar;
|
||||
}, [pinler]);
|
||||
|
||||
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
|
||||
const sf = vb.w / HARITA_GENISLIK;
|
||||
const zoomda = Boolean(seciliIl);
|
||||
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
|
||||
const acikPinler = cakismaAc(seciliPinler);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
|
||||
>
|
||||
{/* İl zeminleri */}
|
||||
{cities.map((city) => {
|
||||
const key = normalizeIlAdi(city.name);
|
||||
const iceriyor = ilPinleri.has(key);
|
||||
const secili = key === seciliIl;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
ref={(el) => {
|
||||
if (el) pathRefs.current.set(key, el);
|
||||
else pathRefs.current.delete(key);
|
||||
}}
|
||||
d={city.path}
|
||||
className={
|
||||
iceriyor
|
||||
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
|
||||
: undefined
|
||||
}
|
||||
fill={
|
||||
secili
|
||||
? "oklch(0.97 0.014 254.6)" // blue-50: seçili zemin
|
||||
: iceriyor
|
||||
? "oklch(0.882 0.059 254.1)" // blue-200
|
||||
: "oklch(0.929 0.013 255.5)" // slate-200
|
||||
}
|
||||
stroke="white"
|
||||
strokeWidth={secili ? 1.6 * sf : 1 * sf}
|
||||
onClick={() => {
|
||||
if (secili) onSeciliIlDegisti(null);
|
||||
else if (iceriyor) onSeciliIlDegisti(key);
|
||||
}}
|
||||
>
|
||||
<title>
|
||||
{iceriyor
|
||||
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
|
||||
(a, p) => a + p.tercihSiralari.length,
|
||||
0,
|
||||
)} tercih — yakınlaşmak için tıkla`
|
||||
: city.name}
|
||||
</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ülke seviyesi: il başına toplu pin */}
|
||||
{!zoomda
|
||||
? [...ilPinleri.entries()].map(([il, iller]) => {
|
||||
const adet = iller.reduce(
|
||||
(a, p) => a + p.tercihSiralari.length,
|
||||
0,
|
||||
);
|
||||
const cx =
|
||||
iller.reduce((a, p) => a + p.x, 0) / iller.length;
|
||||
const cy =
|
||||
iller.reduce((a, p) => a + p.y, 0) / iller.length;
|
||||
const renk = RISK_RENK[baskinRisk(iller)];
|
||||
return (
|
||||
<g
|
||||
key={il}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSeciliIlDegisti(il)}
|
||||
role="button"
|
||||
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
|
||||
>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={11}
|
||||
fill={renk}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{adet}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
||||
{/* İl seviyesi: üniversite pinleri */}
|
||||
{zoomda
|
||||
? acikPinler.map((p) => {
|
||||
// Kilitli pinlerde logo da gösterilmez — üniversite kimliği
|
||||
// client'a inmediği gibi görselden de sızmamalı.
|
||||
const logo = p.kilitli ? null : uniLogoYoluFromAd(p.universite);
|
||||
const r = (p.kilitli ? 7 : logo ? 11 : 8) * sf;
|
||||
const etiket = p.kilitli ? null : kisaUniAdi(p.universite);
|
||||
return (
|
||||
<g
|
||||
key={p.id}
|
||||
className={onTercihSec && !p.kilitli ? "cursor-pointer" : undefined}
|
||||
onClick={
|
||||
onTercihSec && !p.kilitli
|
||||
? () => onTercihSec(p.tercihSiralari[0])
|
||||
: undefined
|
||||
}
|
||||
role={p.kilitli ? undefined : "button"}
|
||||
aria-label={
|
||||
p.kilitli
|
||||
? "Kilitli tercih"
|
||||
: `${etiket}: ${p.tercihSiralari.length} tercih`
|
||||
}
|
||||
>
|
||||
{logo ? (
|
||||
<>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={r}
|
||||
fill="white"
|
||||
stroke={RISK_RENK[p.risk]}
|
||||
strokeWidth={2 * sf}
|
||||
/>
|
||||
<image
|
||||
href={logo}
|
||||
x={p.x - 8 * sf}
|
||||
y={p.y - 8 * sf}
|
||||
width={16 * sf}
|
||||
height={16 * sf}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<circle
|
||||
cx={p.x + r * 0.78}
|
||||
cy={p.y - r * 0.78}
|
||||
r={4.5 * sf}
|
||||
fill={RISK_RENK[p.risk]}
|
||||
stroke="white"
|
||||
strokeWidth={1.2 * sf}
|
||||
/>
|
||||
<text
|
||||
x={p.x + r * 0.78}
|
||||
y={p.y - r * 0.78}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={6 * sf}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{p.tercihSiralari.length}
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={r}
|
||||
fill={p.kilitli ? "oklch(0.704 0.04 256.8)" : RISK_RENK[p.risk]}
|
||||
stroke="white"
|
||||
strokeWidth={2 * sf}
|
||||
strokeDasharray={p.kilitli ? `${3 * sf} ${2.5 * sf}` : undefined}
|
||||
/>
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={9 * sf}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{p.kilitli ? "?" : p.tercihSiralari.length}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
{etiket ? (
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y - r - 4 * sf}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5 * sf}
|
||||
fontWeight={600}
|
||||
fill="oklch(0.279 0.041 260.0)" // slate-800
|
||||
stroke="white"
|
||||
strokeWidth={3 * sf}
|
||||
paintOrder="stroke"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{etiket}
|
||||
</text>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</svg>
|
||||
|
||||
{zoomda ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSeciliIlDegisti(null)}
|
||||
className="absolute left-3 top-3 cursor-pointer rounded-full bg-white/90 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Türkiye
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Seçili ilin tercih şeridi (yalnızca zoom'dayken) */}
|
||||
{zoomda && seciliPinler.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<p className="px-1 pb-1 text-xs font-semibold text-slate-500">
|
||||
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
|
||||
</p>
|
||||
<ul className="flex gap-2 overflow-x-auto pb-1">
|
||||
{seciliPinler
|
||||
.flatMap((p) =>
|
||||
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
|
||||
)
|
||||
.sort((a, b) => a.sira - b.sira)
|
||||
.map(({ pin, sira }) => (
|
||||
<li key={sira} className="shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTercihSec ? () => onTercihSec(sira) : undefined}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1.5 pr-3 text-xs transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded-full font-heading text-[10px] font-bold text-white"
|
||||
style={{ backgroundColor: RISK_RENK[pin.risk] }}
|
||||
>
|
||||
{sira}
|
||||
</span>
|
||||
{pin.kilitli ? (
|
||||
<span className="inline-flex items-center gap-1 text-slate-400">
|
||||
<Lock className="size-3" aria-hidden />
|
||||
Kilitli tercih
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{uniLogoYoluFromAd(pin.universite) ? (
|
||||
<Image
|
||||
src={uniLogoYoluFromAd(pin.universite)!}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<span className="max-w-40 truncate font-medium text-slate-700">
|
||||
{kisaUniAdi(pin.universite)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
432
src/features/liste/components/tercih-profili-kapisi.tsx
Normal file
432
src/features/liste/components/tercih-profili-kapisi.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Trophy } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { toast } from "sonner";
|
||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
SihirbazAdimlarLazy,
|
||||
preloadSihirbazAdimlar,
|
||||
} from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
profilKapisiniKapat,
|
||||
profilTamamlandi,
|
||||
useProfilKapisi,
|
||||
type BekleyenHedef,
|
||||
type BekleyenProgram,
|
||||
} from "../hooks/use-tercih-profili";
|
||||
|
||||
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";
|
||||
|
||||
const ADIM_SIRASI: Record<Adim, number> = { giris: 0, sira: 1, sihirbaz: 2 };
|
||||
|
||||
export function TercihProfiliKapisi() {
|
||||
const { acik, duzenle, dogrudanSira, bekleyen, bekleyenHedef, 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}
|
||||
dogrudanSira={dogrudanSira}
|
||||
bekleyen={bekleyen}
|
||||
hedef={bekleyenHedef}
|
||||
profil={profil}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function KapıIcerik({
|
||||
duzenle,
|
||||
dogrudanSira,
|
||||
bekleyen,
|
||||
hedef,
|
||||
profil,
|
||||
}: {
|
||||
duzenle: boolean;
|
||||
dogrudanSira: boolean;
|
||||
bekleyen: BekleyenProgram | null;
|
||||
hedef: BekleyenHedef | null;
|
||||
profil: TercihProfili | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
|
||||
const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
|
||||
|
||||
// Düzenlemede ve navbar "Sıralamanı gir" girişinde doğrudan sıralama adımı
|
||||
const [adim, setAdim] = useState<Adim>(
|
||||
duzenle || dogrudanSira ? "sira" : "giris",
|
||||
);
|
||||
// Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar
|
||||
const [yon, setYon] = useState(1);
|
||||
const [gecisBasladi, setGecisBasladi] = useState(false);
|
||||
const [panelRef, panelOlculeri] = useMeasure();
|
||||
const hareketiAzalt = useReducedMotion();
|
||||
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);
|
||||
// "sihirbaz" hedefinde yönlendirme ve son buton etiketi oturuma göre
|
||||
// değişir; oturum facet isteğiyle paralel çözülür (hero-form deseni).
|
||||
const [girisli, setGirisli] = useState(false);
|
||||
|
||||
function siralamaDegisti(ham: string) {
|
||||
setHata(null);
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
}
|
||||
|
||||
function adimaGec(yeniAdim: Adim) {
|
||||
setYon(ADIM_SIRASI[yeniAdim] > ADIM_SIRASI[adim] ? 1 : -1);
|
||||
setGecisBasladi(true);
|
||||
setAdim(yeniAdim);
|
||||
}
|
||||
|
||||
async function facetYukle(siraDeger: number, turDeger: string) {
|
||||
setYukleniyor(true);
|
||||
setHata(null);
|
||||
try {
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(siraDeger);
|
||||
setTur(turDeger);
|
||||
setFacetler(veri.facetler);
|
||||
adimaGec("sihirbaz");
|
||||
olay("sira_girildi", {
|
||||
kaynak: duzenle ? "duzenle" : "kapi",
|
||||
tur: turDeger,
|
||||
sira_kovasi: siraKovasi(siraDeger),
|
||||
});
|
||||
} catch {
|
||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||
} finally {
|
||||
setYukleniyor(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function giristenIlerle() {
|
||||
if (sira != null) {
|
||||
await facetYukle(sira, tur);
|
||||
return;
|
||||
}
|
||||
adimaGec("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);
|
||||
const yonlendir = profilTamamlandi(yeniProfil, girisli);
|
||||
// "sihirbaz" hedefinde akış /sonuc'ta devam eder (üretim ya da giriş
|
||||
// paneli); "kaydedildi" toast'ı akış bitti izlenimi verirdi.
|
||||
if (!duzenle && !bekleyenVar && hedef !== "sihirbaz") {
|
||||
toast.success("Tercihlerin kaydedildi.");
|
||||
}
|
||||
// Düzenlemede replace: aynı /sonuc sayfasında sira/tur değişince geçmiş
|
||||
// şişmesin; ilk kurulumda push ile hedefe gitsin.
|
||||
if (yonlendir) {
|
||||
if (duzenle) router.replace(yonlendir);
|
||||
else router.push(yonlendir);
|
||||
}
|
||||
}
|
||||
|
||||
const panelYuksekligi =
|
||||
gecisBasladi && panelOlculeri.height > 0 ? panelOlculeri.height : "auto";
|
||||
|
||||
return (
|
||||
<TransitionPanel
|
||||
activeIndex={ADIM_SIRASI[adim]}
|
||||
custom={yon}
|
||||
variants={{
|
||||
enter: (gecisYonu: number) => ({
|
||||
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
height: panelYuksekligi,
|
||||
position: "relative",
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
height: panelYuksekligi,
|
||||
},
|
||||
exit: (gecisYonu: number) => ({
|
||||
zIndex: 0,
|
||||
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
}),
|
||||
}}
|
||||
transition={
|
||||
hareketiAzalt
|
||||
? {
|
||||
opacity: { duration: 0.12 },
|
||||
x: { duration: 0 },
|
||||
height: { duration: 0 },
|
||||
}
|
||||
: {
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
height: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}
|
||||
}
|
||||
>
|
||||
<div ref={panelRef} className="flex flex-col gap-5 px-px">
|
||||
<div>
|
||||
{duzenle ? (
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
Tercihlerini güncelle
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="mt-2 font-heading text-2xl font-bold text-slate-900 first:mt-0">
|
||||
{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"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={panelRef}
|
||||
onSubmit={siraGonder}
|
||||
className="flex flex-col gap-5 px-px"
|
||||
>
|
||||
<div>
|
||||
{duzenle ? (
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
Sıralama ve tercihler
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="mt-2 font-heading text-xl font-bold text-slate-900 first:mt-0">
|
||||
{duzenle
|
||||
? "Yeni sıralaman nedir?"
|
||||
: "Başarı sıralaman nedir?"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{duzenle
|
||||
? "Sıralamanı veya puan türünü güncelle; sonraki adımda tercihlerini de değiştirebilirsin. Listedeki programlar silinmez."
|
||||
: "Risk ve erişilebilir programlar sıralamana göre hesaplanır."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="kapisi-siralama"
|
||||
className="text-xs font-semibold uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
Başarı sıralaması ve puan türü
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative sm:flex-1">
|
||||
<Trophy
|
||||
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
id="kapisi-siralama"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="ör. 85.000"
|
||||
value={siralama}
|
||||
onChange={(e) => siralamaDegisti(e.target.value)}
|
||||
onFocus={preloadSihirbazAdimlar}
|
||||
aria-invalid={hata ? true : undefined}
|
||||
aria-describedby={hata ? "kapisi-siralama-hata" : undefined}
|
||||
className="h-12 bg-background pl-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Select value={tur} onValueChange={setTur}>
|
||||
<SelectTrigger
|
||||
aria-label="Puan türü"
|
||||
className="w-full bg-background data-[size=default]:h-12 sm:w-44"
|
||||
>
|
||||
<SelectValue placeholder="Puan türü" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PUAN_TURLERI_SECENEK.map((t) => (
|
||||
<SelectItem key={t.deger} value={t.deger}>
|
||||
{t.etiket}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{hata ? (
|
||||
<p
|
||||
id="kapisi-siralama-hata"
|
||||
role="alert"
|
||||
className="text-sm font-medium text-red-600"
|
||||
>
|
||||
{hata}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
// Doğrudan sıralama adımıyla açıldıysa tanıtım adımı hiç
|
||||
// görülmedi; "Geri" yerine kapıyı kapatmak beklenen davranış.
|
||||
duzenle || dogrudanSira ? profilKapisiniKapat() : adimaGec("giris")
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{duzenle || dogrudanSira ? "Vazgeç" : "Geri"}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={yukleniyor}
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Tercihlere geç"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div ref={panelRef} className="px-px">
|
||||
{facetler && sira != null ? (
|
||||
<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"
|
||||
: hedef === "sihirbaz"
|
||||
? girisli
|
||||
? "Yapay Zeka listemi kur (3 kredi)"
|
||||
: "Ücretsiz tablonu gör"
|
||||
: "Tercihlerimi kaydet"
|
||||
}
|
||||
onTamamla={secimleriTamamla}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TransitionPanel>
|
||||
);
|
||||
}
|
||||
291
src/features/liste/hooks/use-tercih-profili.ts
Normal file
291
src/features/liste/hooks/use-tercih-profili.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
"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 "@/types/yokatlas";
|
||||
import { PROFIL_DEGISTI_EVENT, sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku, tercihProfiliTamamlandiMi } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
ekle,
|
||||
listedeMi,
|
||||
MANUEL_LISTE_MAX,
|
||||
programdanManuelTercih,
|
||||
riskleriYenile,
|
||||
useManuelListe,
|
||||
type ManuelKaynak,
|
||||
} from "../liste-store";
|
||||
|
||||
export type BekleyenProgram = {
|
||||
program: Pick<
|
||||
Program,
|
||||
| "id"
|
||||
| "isim"
|
||||
| "universite"
|
||||
| "il"
|
||||
| "unitur"
|
||||
| "tur"
|
||||
| "sira2025"
|
||||
| "sira2024"
|
||||
>;
|
||||
kaynak: ManuelKaynak;
|
||||
adaySira?: number;
|
||||
adayTur?: string;
|
||||
};
|
||||
|
||||
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir).
|
||||
* "sonuc": ücretsiz tabloya götürür. "sihirbaz": Yapay Zeka niyetli CTA'lardan
|
||||
* gelindi — /sonuc'ta üretim akışı devam etsin (girişliyse otomatik üretim,
|
||||
* girişsizse giriş CTA paneli; hero-form ile aynı desen). */
|
||||
export type BekleyenHedef = "sonuc" | "sihirbaz";
|
||||
|
||||
type KapıDurumu = {
|
||||
acik: boolean;
|
||||
duzenle: boolean;
|
||||
/** Tanıtım (giriş) adımını atla, doğrudan sıralama adımıyla aç. */
|
||||
dogrudanSira: boolean;
|
||||
bekleyen: BekleyenProgram | null;
|
||||
bekleyenHedef: BekleyenHedef | null;
|
||||
profil: TercihProfili | null;
|
||||
profilYuklendi: boolean;
|
||||
};
|
||||
|
||||
let durum: KapıDurumu = {
|
||||
acik: false,
|
||||
duzenle: false,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: 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();
|
||||
});
|
||||
// Aynı sekmedeki yazmalar (hero formu, Yapay Zeka sihirbazı) storage olayı
|
||||
// tetiklemez; tercihProfiliYaz/Sil bu olayı yayınlar.
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, () => {
|
||||
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,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: 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,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
};
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function profilDuzenlemeyiAc() {
|
||||
if (!durum.profilYuklendi) yukle();
|
||||
durum = {
|
||||
...durum,
|
||||
acik: true,
|
||||
duzenle: true,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
};
|
||||
yayinla();
|
||||
}
|
||||
|
||||
/**
|
||||
* Profil yokken kapıyı açar; tamamlanınca `hedef`'e yönlendirilir.
|
||||
* Navbar, çekmece +, /sonuc boş hali ve listem gibi yerler bunu kullanır.
|
||||
* `dogrudanSira` tanıtım adımını atlar — kapı sıralama adımıyla açılır.
|
||||
*/
|
||||
export function profilToplamaAc(
|
||||
hedef: BekleyenHedef | null = null,
|
||||
dogrudanSira = false,
|
||||
) {
|
||||
if (!durum.profilYuklendi) yukle();
|
||||
durum = {
|
||||
...durum,
|
||||
acik: true,
|
||||
duzenle: false,
|
||||
dogrudanSira,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: hedef,
|
||||
};
|
||||
yayinla();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
|
||||
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
|
||||
* `dogrudanSira`, kapı açılırsa tanıtım adımını atlatır (navbar CTA'sı).
|
||||
*/
|
||||
export function sonucaGitIste(secenekler?: { dogrudanSira?: boolean }): {
|
||||
href: string | null;
|
||||
kapiAcildi: boolean;
|
||||
} {
|
||||
if (!durum.profilYuklendi) yukle();
|
||||
const profil = durum.profil ?? tercihProfiliOku();
|
||||
if (profil) {
|
||||
return { href: sonucHref(profil), kapiAcildi: false };
|
||||
}
|
||||
profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false);
|
||||
return { href: null, kapiAcildi: true };
|
||||
}
|
||||
|
||||
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,
|
||||
dogrudanSira: false,
|
||||
bekleyen: { program, kaynak, adaySira, adayTur },
|
||||
bekleyenHedef: null,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner.
|
||||
* `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını
|
||||
* belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */
|
||||
export function profilTamamlandi(
|
||||
profil: TercihProfili,
|
||||
girisli = false,
|
||||
): string | null {
|
||||
const onceki = durum.profil;
|
||||
const duzenle = durum.duzenle;
|
||||
riskleriYenile(profil);
|
||||
const bekleyen = durum.bekleyen;
|
||||
const hedef = durum.bekleyenHedef;
|
||||
if (bekleyen) {
|
||||
ekle(programdanManuelTercih(bekleyen.program, profil.sira, bekleyen.kaynak));
|
||||
}
|
||||
durum = {
|
||||
...durum,
|
||||
acik: false,
|
||||
duzenle: false,
|
||||
dogrudanSira: false,
|
||||
bekleyen: null,
|
||||
bekleyenHedef: null,
|
||||
profil,
|
||||
profilYuklendi: true,
|
||||
};
|
||||
yayinla();
|
||||
if (hedef === "sihirbaz") {
|
||||
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
|
||||
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
|
||||
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
|
||||
return sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" });
|
||||
}
|
||||
if (hedef === "sonuc") return sonucHref(profil);
|
||||
// Düzenlemede sıra/tür değişince /sonuc?sira= URL'si yenilenmezse sunucudaki
|
||||
// hayal/dengeli/garanti tablosu eski sıralamada kalır (profil localStorage'da
|
||||
// güncellenir ama sayfa searchParams'tan hesaplanır).
|
||||
if (
|
||||
duzenle &&
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname.startsWith("/sonuc") &&
|
||||
(onceki?.sira !== profil.sira || onceki?.tur !== profil.tur)
|
||||
) {
|
||||
return sonucHref(profil);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useListeDolu(): boolean {
|
||||
const liste = useManuelListe();
|
||||
return liste.length >= MANUEL_LISTE_MAX;
|
||||
}
|
||||
91
src/features/liste/liste-actions.ts
Normal file
91
src/features/liste/liste-actions.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
// Manuel liste + sihirbaz profilinin sunucu kaydı (İP-C). Upsert deseni
|
||||
// reports ile aynıdır (bkz. sonuc/actions.ts): userId unique, tek satır.
|
||||
//
|
||||
// Senkron kuralları (PRD C1):
|
||||
// - Giriş anında listeSenkronla: çakışmada CLIENT kazanır (lokal sıra başa,
|
||||
// sunucudaki eksik id'ler sona), 24 sınırı korunur; idempotent — aynı
|
||||
// girdiyle tekrar çağrı aynı sonucu üretir, çift kayıt imkânsız.
|
||||
// - Girişliyken listeyiKaydet: localStorage tek kaynaktır, sunucu kaydının
|
||||
// üzerine yazılır (son yazan cihaz kazanır).
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { tercihProfiliDogrula, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
type ManuelTercih,
|
||||
} from "@/features/liste/liste-dogrula";
|
||||
|
||||
export type ListeSenkronSonucu =
|
||||
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }
|
||||
| { ok: false };
|
||||
|
||||
async function kaydiYaz(
|
||||
userId: string,
|
||||
items: ManuelTercih[],
|
||||
profil: TercihProfili | null,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await appDb
|
||||
.insert(schema.savedLists)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
items,
|
||||
profil,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.savedLists.userId,
|
||||
set: { items, profil, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
/** Giriş anı merge'i: lokal + sunucu birleşir, kanonik sonuç döner. */
|
||||
export async function listeSenkronla(girdi: {
|
||||
items: unknown;
|
||||
profil: unknown;
|
||||
}): Promise<ListeSenkronSonucu> {
|
||||
const session = await getSession();
|
||||
if (!session) return { ok: false };
|
||||
const userId = session.user.id;
|
||||
|
||||
const clientItems = dogrula(girdi.items);
|
||||
const clientProfil = tercihProfiliDogrula(girdi.profil);
|
||||
|
||||
const mevcut = await appDb.query.savedLists.findFirst({
|
||||
where: eq(schema.savedLists.userId, userId),
|
||||
});
|
||||
const sunucuItems = dogrula(mevcut?.items);
|
||||
const sunucuProfil = tercihProfiliDogrula(mevcut?.profil);
|
||||
|
||||
const clientIdler = new Set(clientItems.map((t) => t.id));
|
||||
const items = [
|
||||
...clientItems,
|
||||
...sunucuItems.filter((t) => !clientIdler.has(t.id)),
|
||||
].slice(0, MANUEL_LISTE_MAX);
|
||||
const profil = clientProfil ?? sunucuProfil;
|
||||
|
||||
await kaydiYaz(userId, items, profil);
|
||||
return { ok: true, items, profil };
|
||||
}
|
||||
|
||||
/** Girişliyken süregelen senkron: lokal durum sunucunun üzerine yazılır. */
|
||||
export async function listeyiKaydet(girdi: {
|
||||
items: unknown;
|
||||
profil: unknown;
|
||||
}): Promise<{ ok: boolean }> {
|
||||
const session = await getSession();
|
||||
if (!session) return { ok: false };
|
||||
await kaydiYaz(
|
||||
session.user.id,
|
||||
dogrula(girdi.items),
|
||||
tercihProfiliDogrula(girdi.profil),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
63
src/features/liste/liste-dogrula.ts
Normal file
63
src/features/liste/liste-dogrula.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Manuel liste kaydının saf doğrulama katmanı. store.ts ("use client")
|
||||
// buradan re-export eder; kayitli-liste-actions.ts ("use server") sunucuda
|
||||
// aynı süzgeci kullanır — client/sunucu iki ayrı doğrulama yazılmaz.
|
||||
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
export const MANUEL_LISTE_MAX = 24;
|
||||
|
||||
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 / profil anındaki aday sıralamasına göre risk */
|
||||
risk: RiskSeviyesi | null;
|
||||
kaynak?: ManuelKaynak;
|
||||
};
|
||||
|
||||
/** Güvenilmeyen JSON'u (localStorage veya sunucu kaydı) listeye süzer. */
|
||||
export function dogrula(ham: unknown): ManuelTercih[] {
|
||||
if (!Array.isArray(ham)) return [];
|
||||
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;
|
||||
}
|
||||
21
src/features/liste/liste-giris-url.ts
Normal file
21
src/features/liste/liste-giris-url.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
// Mevcut sayfaya (query dahil) geri dönen /giris URL'i. Render'ı saf tutmak
|
||||
// için window.location yalnızca effect'te okunur; ilk render callback'siz
|
||||
// /giris ile başlar (banner/çekmece zaten mount sonrası görünür).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function useGirisUrl(): string {
|
||||
const pathname = usePathname();
|
||||
const [url, setUrl] = useState("/giris");
|
||||
useEffect(() => {
|
||||
try {
|
||||
const hedef = `${window.location.pathname}${window.location.search}`;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setUrl(`/giris?callback=${encodeURIComponent(hedef)}`);
|
||||
} catch {}
|
||||
}, [pathname]);
|
||||
return url;
|
||||
}
|
||||
34
src/features/liste/liste-oturum-sinyali.ts
Normal file
34
src/features/liste/liste-oturum-sinyali.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
// Oturum durumunun client tarafındaki hafif sinyali. Kaynak: UserNav (sunucu
|
||||
// bileşeni) → ListeSenkron mount'ta bildirir. Çekmece ve kaydet banner'ı
|
||||
// authClient round-trip'i atmadan "girişsiz mi?" bilgisine buradan erişir
|
||||
// (layout'a useSession koymak landing'i statiklikten çıkarırdı —
|
||||
// rybbit-identify.tsx'teki gerekçe).
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
let girisli: boolean | null = null;
|
||||
const dinleyiciler = new Set<() => void>();
|
||||
|
||||
export function oturumDurumuBildir(deger: boolean): void {
|
||||
if (girisli === deger) return;
|
||||
girisli = deger;
|
||||
for (const cb of dinleyiciler) cb();
|
||||
}
|
||||
|
||||
function abone(cb: () => void) {
|
||||
dinleyiciler.add(cb);
|
||||
return () => {
|
||||
dinleyiciler.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/** null = henüz bilinmiyor (sinyal gelmedi); banner bu durumda gösterilmez. */
|
||||
export function useOturumDurumu(): boolean | null {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => girisli,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
218
src/features/liste/liste-store.ts
Normal file
218
src/features/liste/liste-store.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
// Manuel 24'lük liste — Yapay Zeka'dan bağımsız, kullanıcının tablodan "+" ile kendi
|
||||
// kurduğu tercih listesi. Kaynak tek: bu modüldeki harici store. Navbar rozeti,
|
||||
// çekmece ve program tablosu aynı store'a abone olur; kalıcılık localStorage'ta
|
||||
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { riskHesapla } from "@/lib/risk";
|
||||
import { type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
type ManuelKaynak,
|
||||
type ManuelTercih,
|
||||
} from "./liste-dogrula";
|
||||
|
||||
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
|
||||
// mevcut import yolları kırılmasın diye buradan re-export edilir.
|
||||
export { MANUEL_LISTE_MAX, type ManuelKaynak, type ManuelTercih };
|
||||
|
||||
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
|
||||
|
||||
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
|
||||
const BOS_LISTE: ManuelTercih[] = [];
|
||||
|
||||
let liste: ManuelTercih[] = BOS_LISTE;
|
||||
let cekmeceAcik = false;
|
||||
let yuklendi = false;
|
||||
|
||||
const dinleyiciler = new Set<() => void>();
|
||||
|
||||
function yayinla() {
|
||||
for (const cb of dinleyiciler) cb();
|
||||
}
|
||||
|
||||
function kaydet() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(liste));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function yukle() {
|
||||
if (yuklendi || typeof window === "undefined") return;
|
||||
yuklendi = true;
|
||||
try {
|
||||
const ham = localStorage.getItem(STORAGE_KEY);
|
||||
if (ham) liste = dogrula(JSON.parse(ham));
|
||||
} catch {}
|
||||
// Başka sekmede yapılan değişiklikleri de yansıt
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
try {
|
||||
liste = e.newValue ? dogrula(JSON.parse(e.newValue)) : [];
|
||||
yayinla();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
function abone(cb: () => void) {
|
||||
dinleyiciler.add(cb);
|
||||
if (!yuklendi) {
|
||||
// İlk client abonesi: storage'ı oku ve snapshot'ı tazele
|
||||
yukle();
|
||||
queueMicrotask(yayinla);
|
||||
}
|
||||
return () => {
|
||||
dinleyiciler.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function useManuelListe(): ManuelTercih[] {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => liste,
|
||||
() => BOS_LISTE,
|
||||
);
|
||||
}
|
||||
|
||||
export function useCekmeceAcik(): boolean {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => cekmeceAcik,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function listedeMi(id: string): boolean {
|
||||
return liste.some((t) => t.id === id);
|
||||
}
|
||||
|
||||
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */
|
||||
export function listeOku(): ManuelTercih[] {
|
||||
yukle();
|
||||
return liste;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sunucudan dönen kanonik listeyi lokale birleştirir: lokal sıra korunur,
|
||||
* lokalde olmayan sunucu kayıtları sona eklenir, 24 sınırı aşılmaz.
|
||||
* (Çakışmada localStorage kazanır — PRD C1 kuralı.)
|
||||
*/
|
||||
export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[] {
|
||||
yukle();
|
||||
const mevcut = new Set(liste.map((t) => t.id));
|
||||
const ekler = sunucuItems.filter((t) => !mevcut.has(t.id));
|
||||
if (ekler.length === 0) return liste;
|
||||
liste = [...liste, ...ekler].slice(0, MANUEL_LISTE_MAX);
|
||||
kaydet();
|
||||
yayinla();
|
||||
return liste;
|
||||
}
|
||||
|
||||
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;
|
||||
const profil = tercihProfiliOku();
|
||||
const kayit: ManuelTercih = {
|
||||
...tercih,
|
||||
risk:
|
||||
profil != null
|
||||
? riskHesapla(tercih.efektifSira, profil.sira)
|
||||
: tercih.risk,
|
||||
};
|
||||
liste = [...liste, kayit];
|
||||
kaydet();
|
||||
yayinla();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cikar(id: string) {
|
||||
liste = liste.filter((t) => t.id !== id);
|
||||
kaydet();
|
||||
yayinla();
|
||||
}
|
||||
|
||||
/** Sürükle-bırak sonrası yeni sıra (motion Reorder'dan gelir) */
|
||||
export function yenidenSirala(yeni: ManuelTercih[]) {
|
||||
liste = yeni;
|
||||
kaydet();
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function temizle() {
|
||||
liste = [];
|
||||
kaydet();
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function cekmeceAc() {
|
||||
cekmeceAcik = true;
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function cekmeceKapat() {
|
||||
cekmeceAcik = false;
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function cekmeceDegistir() {
|
||||
cekmeceAcik = !cekmeceAcik;
|
||||
yayinla();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
|
||||
// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa
|
||||
// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
|
||||
export function HeroFocusButton() {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "./sihirbaz-adimlar-lazy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
|
||||
@@ -21,7 +21,7 @@ import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
|
||||
@@ -13,7 +13,7 @@ import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
sonucaGitIste,
|
||||
useProfilKapisi,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
|
||||
export function SiraGerekli() {
|
||||
const router = useRouter();
|
||||
|
||||
Reference in New Issue
Block a user