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:
bilalgursen
2026-08-08 01:34:50 +03:00
parent 792e8c7346
commit 7d2b2ddd71
37 changed files with 43 additions and 43 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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";

View 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>
);
}

View 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 },
);

View 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 {" "}
<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>
);
}

View 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 },
);

View 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
<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>
);
}

View 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;
}

View 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 />
{/* lgxl: 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import dynamic from "next/dynamic";
export const TercihProfiliKapisiLazy = dynamic(
() => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi),
{ ssr: false },
);

View File

@@ -0,0 +1,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>
);
}

View 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ıı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;
}

View 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 };
}

View 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;
}

View 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;
}

View 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,
);
}

View 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ıı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();
}

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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();