Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s

- Added new scripts for database operations and temporary production in package.json.
- Integrated KaydetBannerLazy component into the layout for improved user notifications.
- Enhanced API error handling in the soru route to mark credit exhaustion.
- Updated the IletisimPage for better button styling and user experience.
- Refactored ListemPage to streamline user flow and improve session handling.
- Removed unused ListePaneli component to clean up the codebase.
This commit is contained in:
bilalgursen
2026-08-06 02:27:42 +03:00
parent 488845ae1f
commit e62c6f07df
77 changed files with 4132 additions and 1659 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
ChartNoAxesCombined,
@@ -76,12 +76,15 @@ const KANITLAR = [
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const profil = useTercihProfili();
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
function sihirbazaGit() {
onSec();
if (pathname === "/sonuc") {
// Orkestratör (SihirbazBolumu) yalnızca /sonuc?sira= geçerliyken monte;
// sırasız /sonuc'ta (SiraGerekli görünümü) olay boşa gider — kapıya düş.
if (pathname === "/sonuc" && searchParams.get("sira")) {
window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT));
return;
}
@@ -89,7 +92,9 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
router.push(sonucHref(profil, { sihirbaz: "1" }));
return;
}
profilToplamaAc("sonuc");
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam
// etmeli ("sonuc" hedefi ücretsiz tabloda bırakıyor, liste hiç kurulmuyordu).
profilToplamaAc("sihirbaz");
}
return (
@@ -134,7 +139,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
<Button
size="lg"
onClick={sihirbazaGit}
className="h-11 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
className="w-full shrink-0 cursor-pointer bg-orange-500 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />

View File

@@ -11,6 +11,7 @@ import {
ProgramTabanTrendi,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import { UniLogo } from "@/components/uni-logo";
import {
Table,
@@ -55,7 +56,7 @@ function SiralamaDugmesi({
aktif && yon === "artan" ? "azalan" : "artan"
} sırala`}
aria-pressed={aktif}
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span>{etiket}</span>
<span className="flex flex-col -space-y-1" aria-hidden>
@@ -180,8 +181,8 @@ export function BolumProgramListesi({
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
<TableHead className="w-24 text-right">
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -254,11 +255,19 @@ export function BolumProgramListesi({
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
<span className="inline-flex items-center gap-1.5">
<ProgramNetleriButonu
programId={p.id}
isim={p.isim.trim()}
universite={uniAd}
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
</span>
</TableCell>
</TableRow>
);

View File

@@ -60,7 +60,7 @@ const KANITLAR = [
*
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
* Tasarım gerekçesi için bkz. CTA-CEKMECE-TASARIMI.md.
* Tasarım gerekçesi için bkz. docs/tasarim/cta-cekmece-tasarimi.md.
*/
export function CtaSiraForm({ baslik }: { baslik?: string }) {
const profil = useTercihProfili();
@@ -153,7 +153,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
</p>
<Link
href={sonucHref(profil)}
className="mt-4 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-orange-500 px-7 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
>
Sıralamana uygun programları gör
<ArrowRight className="size-4" aria-hidden />

View File

@@ -3,14 +3,17 @@
import { usePathname } from "next/navigation";
// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar
// (tam eşleşme veya alt yol)
// (tam eşleşme veya alt yol). KaydetBanner da aynı listeye uyar.
const HIDDEN_PREFIXES = ["/giris"];
export function kromGizliMi(pathname: string) {
return HIDDEN_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
}
export function HeaderGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const gizli = HIDDEN_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
if (gizli) return null;
if (kromGizliMi(pathname)) return null;
return <>{children}</>;
}

View File

@@ -22,7 +22,7 @@ export function HeroFocusButton() {
type="button"
size="lg"
onClick={handleClick}
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Ücretsiz dene
</Button>
@@ -47,7 +47,7 @@ export function KapanisCta() {
<Button
asChild
size="lg"
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={sonucHref(profil)}>
Üniversitelere göz at

View File

@@ -116,7 +116,7 @@ export function HeroForm() {
<Button
asChild
size="lg"
className="h-12 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
>
<Link href={sonucHref(kayitliProfil)}>
Üniversitelere göz at
@@ -140,7 +140,9 @@ export function HeroForm() {
}
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
<div className="flex w-full max-w-xl flex-col items-center gap-3">
<div
role="radiogroup"
aria-label="Puan türü"
@@ -190,9 +192,9 @@ export function HeroForm() {
type="submit"
size="lg"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? "Hazırlanıyor…" : "Yapay Zeka listemi kur"}
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>

View File

@@ -87,7 +87,9 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const [acik, setAcik] = useState(false);
function siralamaGirVeyaSonucaGit() {
const { href } = sonucaGitIste();
// Navbar CTA'sı "Sıralamanı gir" dediği için kapı tanıtım adımını
// atlayıp doğrudan başarı sıralaması adımıyla açılır.
const { href } = sonucaGitIste({ dogrudanSira: true });
if (href) router.push(href);
}
const [sorgu, setSorgu] = useState("");

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,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,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 "./store";
import { useOturumDurumu } from "./oturum-sinyali";
import { useGirisUrl } from "./giris-url";
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
const ESIK = 3;
export function KaydetBanner() {
const liste = useManuelListe();
const girisli = useOturumDurumu();
const pathname = usePathname();
const girisUrl = useGirisUrl();
// SSR/ilk boyada gizli başla; kapatma durumu mount'ta okunur.
const [kapali, setKapali] = useState(true);
useEffect(() => {
try {
// eslint-disable-next-line react-hooks/set-state-in-effect
setKapali(Boolean(sessionStorage.getItem(KAPATMA_KEY)));
} catch {
setKapali(false);
}
}, []);
const gorunumRef = useGorunumOlayi<HTMLDivElement>(
"kaydet_banner_goruntulendi",
);
if (girisli !== false || liste.length < ESIK || kapali) return null;
// /sonuc'ta gösterilmez: sayfanın kendi funnel panelleri var.
if (pathname?.startsWith("/sonuc")) return null;
// Giriş gibi kromsuz sayfalarda gösterilmez: kullanıcı zaten giriş
// akışındayken "Giriş yap" CTA'sı gereksiz gürültü olur.
if (pathname && kromGizliMi(pathname)) return null;
return (
<ViewportPortal>
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
<div
ref={gorunumRef}
className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl"
>
{/* Kullanıcının o anki işletim sisteminin logosu: "bu tarayıcıda"
mesajını somutlaştırır */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-700">
<IsletimSistemiLogosu className="size-5" />
</div>
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
Listen yalnızca bu tarayıcıda
</p>
<p className="text-xs text-slate-500">
Giriş yap; {liste.length} seçimin hesabında saklansın, her
cihazdan aç{" "}
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
</p>
</div>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "liste_kaydet_banner" })
}
>
Giriş yap, kaydet
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<button
type="button"
aria-label="Kapat"
onClick={() => {
setKapali(true);
try {
sessionStorage.setItem(KAPATMA_KEY, "1");
} catch {}
}}
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
</button>
</div>
</div>
</ViewportPortal>
);
}

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 "@/lib/sihirbaz";
import {
MANUEL_LISTE_MAX,
dogrula,
type ManuelTercih,
} from "@/components/manuel-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

@@ -45,7 +45,11 @@ import {
} 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 "./profil-kapisi-store";
import { useOturumDurumu } from "./oturum-sinyali";
import { useGirisUrl } from "./giris-url";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
@@ -81,6 +85,8 @@ 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);
@@ -216,7 +222,7 @@ export function ListeCekmecesi() {
<button
type="button"
onClick={programaEkleGit}
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-xl 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] ${
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"
}`}
>
@@ -227,6 +233,32 @@ export function ListeCekmecesi() {
</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">
@@ -247,7 +279,7 @@ export function ListeCekmecesi() {
<Button
asChild
size="lg"
className="h-11 shrink-0 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem" onClick={cekmeceKapat}>
Listemde aç

View File

@@ -0,0 +1,100 @@
"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,
tercihProfiliOku,
tercihProfiliYaz,
} from "@/lib/sihirbaz";
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
import { oturumDurumuBildir } from "./oturum-sinyali";
import { listeSenkronla, listeyiKaydet } from "./kayitli-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,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

@@ -39,12 +39,17 @@ export type BekleyenProgram = {
adayTur?: string;
};
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir). */
export type BekleyenHedef = "sonuc";
/** 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;
@@ -54,6 +59,7 @@ type KapıDurumu = {
let durum: KapıDurumu = {
acik: false,
duzenle: false,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
profil: null,
@@ -104,6 +110,7 @@ function abone(cb: () => void) {
const SSR_DURUM: KapıDurumu = {
acik: false,
duzenle: false,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
profil: null,
@@ -130,6 +137,7 @@ export function profilKapisiniKapat() {
...durum,
acik: false,
duzenle: false,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
};
@@ -142,6 +150,7 @@ export function profilDuzenlemeyiAc() {
...durum,
acik: true,
duzenle: true,
dogrudanSira: false,
bekleyen: null,
bekleyenHedef: null,
};
@@ -151,13 +160,18 @@ export function profilDuzenlemeyiAc() {
/**
* 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) {
export function profilToplamaAc(
hedef: BekleyenHedef | null = null,
dogrudanSira = false,
) {
if (!durum.profilYuklendi) yukle();
durum = {
...durum,
acik: true,
duzenle: false,
dogrudanSira,
bekleyen: null,
bekleyenHedef: hedef,
};
@@ -167,8 +181,9 @@ export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
/**
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
* `dogrudanSira`, kapı açılırsa tanıtım adımını atlatır (navbar CTA'sı).
*/
export function sonucaGitIste(): {
export function sonucaGitIste(secenekler?: { dogrudanSira?: boolean }): {
href: string | null;
kapiAcildi: boolean;
} {
@@ -177,7 +192,7 @@ export function sonucaGitIste(): {
if (profil) {
return { href: sonucHref(profil), kapiAcildi: false };
}
profilToplamaAc("sonuc");
profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false);
return { href: null, kapiAcildi: true };
}
@@ -203,6 +218,7 @@ export function programEkleIste(opts: {
...durum,
acik: true,
duzenle: false,
dogrudanSira: false,
bekleyen: { program, kaynak, adaySira, adayTur },
bekleyenHedef: null,
};
@@ -227,8 +243,13 @@ export function programEkleIste(opts: {
return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false };
}
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner. */
export function profilTamamlandi(profil: TercihProfili): string | null {
/** 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);
@@ -241,12 +262,19 @@ export function profilTamamlandi(profil: TercihProfili): string | null {
...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

View File

@@ -7,31 +7,24 @@
import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { riskHesapla } from "@/lib/risk";
import {
tercihProfiliOku,
type TercihProfili,
} from "@/lib/sihirbaz";
import {
MANUEL_LISTE_MAX,
dogrula,
type ManuelKaynak,
type ManuelTercih,
} from "./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 };
export const MANUEL_LISTE_MAX = 24;
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
export type ManuelTercih = {
/** yokatlas program id'si */
id: string;
isim: string;
universite: string;
il: string | null;
unitur: string | null;
tur: string | null;
efektifSira: number | null;
/** Ekleme / profil anındaki aday sıralamasına göre risk */
risk: RiskSeviyesi | null;
kaynak?: ManuelKaynak;
};
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
const BOS_LISTE: ManuelTercih[] = [];
@@ -51,45 +44,6 @@ function kaydet() {
} catch {}
}
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;
}
function yukle() {
if (yuklendi || typeof window === "undefined") return;
yuklendi = true;
@@ -139,6 +93,28 @@ export function listedeMi(id: string): boolean {
return liste.some((t) => t.id === id);
}
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */
export function listeOku(): ManuelTercih[] {
yukle();
return liste;
}
/**
* Sunucudan dönen kanonik listeyi lokale birleştirir: lokal sıra korunur,
* lokalde olmayan sunucu kayıtları sona eklenir, 24 sınırı aşılmaz.
* (Çakışmada localStorage kazanır — PRD C1 kuralı.)
*/
export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[] {
yukle();
const mevcut = new Set(liste.map((t) => t.id));
const ekler = sunucuItems.filter((t) => !mevcut.has(t.id));
if (ekler.length === 0) return liste;
liste = [...liste, ...ekler].slice(0, MANUEL_LISTE_MAX);
kaydet();
yayinla();
return liste;
}
export function efektifSiraAl(
p: Pick<Program, "sira2025" | "sira2024">,
): number | null {

View File

@@ -33,10 +33,12 @@ import {
type SihirbazSecimleri,
type TercihProfili,
} from "@/lib/sihirbaz";
import { authClient } from "@/lib/auth-client";
import {
profilKapisiniKapat,
profilTamamlandi,
useProfilKapisi,
type BekleyenHedef,
type BekleyenProgram,
} from "./profil-kapisi-store";
@@ -53,7 +55,8 @@ type Adim = "giris" | "sira" | "sihirbaz";
const ADIM_SIRASI: Record<Adim, number> = { giris: 0, sira: 1, sihirbaz: 2 };
export function TercihProfiliKapisi() {
const { acik, duzenle, bekleyen, profil } = useProfilKapisi();
const { acik, duzenle, dogrudanSira, bekleyen, bekleyenHedef, profil } =
useProfilKapisi();
const oturumAnahtari = acik
? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}`
: "kapali";
@@ -80,7 +83,9 @@ export function TercihProfiliKapisi() {
<KapıIcerik
key={oturumAnahtari}
duzenle={duzenle}
dogrudanSira={dogrudanSira}
bekleyen={bekleyen}
hedef={bekleyenHedef}
profil={profil}
/>
) : null}
@@ -91,19 +96,25 @@ export function TercihProfiliKapisi() {
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 doğrudan sıralama adımı — yeni sıra + tercihler aynı oturumda
const [adim, setAdim] = useState<Adim>(duzenle ? "sira" : "giris");
// 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);
@@ -117,6 +128,9 @@ function KapıIcerik({
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);
@@ -134,11 +148,13 @@ function KapıIcerik({
setYukleniyor(true);
setHata(null);
try {
const res = await fetch(
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
);
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);
@@ -179,8 +195,10 @@ function KapıIcerik({
const yeniProfil: TercihProfili = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil);
const bekleyenVar = Boolean(bekleyen);
const yonlendir = profilTamamlandi(yeniProfil);
if (!duzenle && !bekleyenVar) {
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ş
@@ -363,11 +381,13 @@ function KapıIcerik({
type="button"
variant="ghost"
onClick={() =>
duzenle ? profilKapisiniKapat() : adimaGec("giris")
// 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 ? "Vazgeç" : "Geri"}
{duzenle || dogrudanSira ? "Vazgeç" : "Geri"}
</Button>
<Button
type="submit"
@@ -399,7 +419,11 @@ function KapıIcerik({
? "Tercihlerimi güncelle"
: bekleyen
? "Kaydet ve programı ekle"
: "Tercihlerimi kaydet"
: hedef === "sihirbaz"
? girisli
? "Yapay Zeka listemi kur (3 kredi)"
: "Ücretsiz tablonu gör"
: "Tercihlerimi kaydet"
}
onTamamla={secimleriTamamla}
/>

View File

@@ -1,12 +1,31 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import type { Program } from "@/lib/db";
export function programEtkinSira(program: Program): number | null {
/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
* rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */
export type SiraSerisi = Pick<
Program,
"sira2021" | "sira2022" | "sira2023" | "sira2024" | "sira2025"
>;
/** Rapordaki [2021..2025] dizisini sütunlu seriye çevirir. */
export function raporSiraSerisi(gecmis?: (number | null)[]): SiraSerisi {
const g = gecmis ?? [];
return {
sira2021: g[0] ?? null,
sira2022: g[1] ?? null,
sira2023: g[2] ?? null,
sira2024: g[3] ?? null,
sira2025: g[4] ?? null,
};
}
export function programEtkinSira(program: SiraSerisi): number | null {
return program.sira2025 ?? program.sira2024;
}
/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: Program }) {
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
const yillar = [
[2021, program.sira2021],
[2022, program.sira2022],
@@ -61,7 +80,7 @@ export function ProgramSiraGecmisi({ program }: { program: Program }) {
}
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
export function ProgramTabanTrendi({ program }: { program: Program }) {
export function ProgramTabanTrendi({ program }: { program: SiraSerisi }) {
if (program.sira2025 == null || program.sira2024 == null) return null;
const oran = (program.sira2024 - program.sira2025) / program.sira2024;

View File

@@ -0,0 +1,221 @@
"use client";
// Program satırındaki "netler" butonu: YÖK Atlas'ın "yerleşen son kişinin
// netleri" verisini (data/yokatlas.db → netler tablosu, /api/netler) yıl
// sütunlu kompakt bir modalde gösterir. Kadın-erkek dağılımı bilinçli olarak
// yok: YÖK Atlas 2026 SPA'sına geçerken cinsiyet panelini yayından kaldırdı
// (bkz. scripts/detay.ts).
import { useState } from "react";
import { ListChecks } from "lucide-react";
import type { ProgramNetSatiri } from "@/lib/db";
import { olay } from "@/lib/analitik";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Ders sırası ÖSYM oturum sırasıyla; max = testteki soru sayısı.
const DERSLER: {
alan: keyof Omit<ProgramNetSatiri, "yil" | "puanTuru" | "tabanPuan" | "obp">;
etiket: string;
max: number;
}[] = [
{ alan: "tytTrkNet", etiket: "TYT Türkçe", max: 40 },
{ alan: "tytSosNet", etiket: "TYT Sosyal Bilimler", max: 20 },
{ alan: "tytMatNet", etiket: "TYT Matematik", max: 40 },
{ alan: "tytFenNet", etiket: "TYT Fen Bilimleri", max: 20 },
{ alan: "aytMatNet", etiket: "AYT Matematik", max: 40 },
{ alan: "aytFizNet", etiket: "AYT Fizik", max: 14 },
{ alan: "aytKimNet", etiket: "AYT Kimya", max: 13 },
{ alan: "aytBioNet", etiket: "AYT Biyoloji", max: 13 },
{ alan: "aytTdeNet", etiket: "AYT Türk Dili ve Edebiyatı", max: 24 },
{ alan: "aytTrh1Net", etiket: "AYT Tarih-1", max: 10 },
{ alan: "aytCog1Net", etiket: "AYT Coğrafya-1", max: 6 },
{ alan: "aytTrh2Net", etiket: "AYT Tarih-2", max: 11 },
{ alan: "aytCog2Net", etiket: "AYT Coğrafya-2", max: 11 },
{ alan: "aytDinNet", etiket: "AYT Din Kültürü", max: 6 },
{ alan: "aytFelNet", etiket: "AYT Felsefe Grubu", max: 12 },
{ alan: "ydtYdilNet", etiket: "YDT Yabancı Dil", max: 80 },
];
const net = (n: number) =>
n.toLocaleString("tr-TR", { maximumFractionDigits: 2 });
type Durum = "bekliyor" | "yukleniyor" | "hazir" | "hata";
export function ProgramNetleriButonu({
programId,
isim,
universite,
className = "",
}: {
programId: string;
isim: string;
universite?: string;
className?: string;
}) {
const [durum, setDurum] = useState<Durum>("bekliyor");
const [netler, setNetler] = useState<ProgramNetSatiri[]>([]);
async function yukle() {
setDurum("yukleniyor");
try {
const res = await fetch(`/api/netler?id=${programId}`);
if (!res.ok) throw new Error(String(res.status));
const veri = (await res.json()) as { netler: ProgramNetSatiri[] };
setNetler(veri.netler);
setDurum("hazir");
} catch {
setDurum("hata");
}
}
function acildi(acik: boolean) {
if (!acik) return;
olay("netler_goruntulendi");
if (durum === "bekliyor" || durum === "hata") yukle();
}
return (
<Dialog onOpenChange={acildi}>
<DialogTrigger asChild>
<button
type="button"
title="Son yerleşenin netleri"
onClick={(e) => e.stopPropagation()}
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
>
<ListChecks className="size-4" aria-hidden />
<span className="sr-only">{isim} — son yerleşenin netleri</span>
</button>
</DialogTrigger>
<DialogContent
className="sm:max-w-md"
onClick={(e) => e.stopPropagation()}
>
<DialogHeader>
<DialogTitle className="pr-10">Son yerleşenin netleri</DialogTitle>
<DialogDescription>
{isim}
{universite ? ` · ${universite}` : ""} — bölüme en düşük puanla
yerleşen adayın sınav netleri (YÖK Atlas).
</DialogDescription>
</DialogHeader>
<NetlerIcerik durum={durum} netler={netler} />
</DialogContent>
</Dialog>
);
}
function NetlerIcerik({
durum,
netler,
}: {
durum: Durum;
netler: ProgramNetSatiri[];
}) {
if (durum === "yukleniyor" || durum === "bekliyor") {
return (
<div className="space-y-2" aria-hidden>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="h-6 animate-pulse rounded-md bg-slate-100" />
))}
</div>
);
}
if (durum === "hata") {
return (
<p className="py-4 text-center text-sm text-slate-500">
Netler yüklenemedi. Modalı kapatıp yeniden açmayı dene.
</p>
);
}
if (netler.length === 0) {
return (
<p className="py-4 text-center text-sm text-slate-500">
Bu program için YÖK Atlas&apos;ta net verisi yayınlanmamış.
</p>
);
}
const dersler = DERSLER.filter((d) =>
netler.some((y) => y[d.alan] != null),
);
return (
<div className="-mx-1 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-9">Ders</TableHead>
{netler.map((y) => (
<TableHead key={y.yil} className="h-9 text-right tabular-nums">
{y.yil}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{dersler.map((d) => (
<TableRow key={d.alan}>
<TableCell className="py-2 text-xs text-slate-600">
{d.etiket}
<span className="text-slate-400"> / {d.max}</span>
</TableCell>
{netler.map((y) => {
const deger = y[d.alan];
return (
<TableCell
key={y.yil}
className="py-2 text-right text-xs tabular-nums"
>
{deger != null ? net(deger) : "—"}
</TableCell>
);
})}
</TableRow>
))}
<TableRow>
<TableCell className="py-2 text-xs font-medium text-slate-600">
OBP
</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2 text-right text-xs tabular-nums text-slate-500"
>
{y.obp != null ? net(y.obp) : "—"}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="py-2 text-xs font-semibold text-slate-700">
Yerleşme puanı
</TableCell>
{netler.map((y) => (
<TableCell
key={y.yil}
className="py-2 text-right text-xs font-semibold tabular-nums text-slate-700"
>
{y.tabanPuan != null ? net(y.tabanPuan) : "—"}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
);
}

View File

@@ -15,7 +15,7 @@ import {
} from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -31,6 +31,7 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import { UniLogo } from "@/components/uni-logo";
const DILIMLER: {
@@ -183,8 +184,8 @@ export function ProgramTablosu({
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-16 text-right">
<span className="sr-only">Listeye ekle</span>
<TableHead className="w-24 text-right">
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -226,22 +227,6 @@ export function ProgramTablosu({
);
}
// /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
// kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek
// için burada tekrarlanır. Katalog haritası DB'deki tüm universite
// değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
function uniSayfaSlug(ad: string): string {
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
}
// /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
// (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
// değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
// (bkz. liste-cekmecesi'ndeki bolumHref).
function bolumSayfaSlug(isim: string): string {
return turkishSlugify(bolumBazAdi(isim));
}
function ProgramSatiri({
program: p,
adaySira,
@@ -336,13 +321,21 @@ function ProgramSatiri({
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<ProgramEkleButonu
program={p}
kaynak="sonuc"
adaySira={adaySira}
adayTur={tur}
className="size-8 min-h-8 min-w-8"
/>
<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

@@ -1,22 +1,32 @@
"use client";
// 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
// herkese görünür. Yapay Zeka gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
// ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
// sunucuda zaten maskelidir).
import { Fragment, useEffect, useRef, useState } from "react";
import {
ChevronDown,
Lock,
MapPin,
Minus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import Link from "next/link";
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
import { UniLogo } from "@/components/uni-logo";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
raporSiraSerisi,
} from "@/components/program-liste-verileri";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import type { RaporSonuc } from "@/lib/ai/rapor";
import {
RISK_ETIKET,
@@ -31,105 +41,66 @@ export const DILIM_ETIKET: Record<string, string> = {
garanti: "Garanti",
};
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
const RISK_STIL: Record<
RiskSeviyesi,
{ nokta: string; kenar: string; icon: typeof Rocket }
> = {
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
"az-riskli": {
nokta: "bg-amber-500",
kenar: "border-l-amber-500",
icon: Scale,
},
guvenli: {
nokta: "bg-emerald-500",
kenar: "border-l-emerald-500",
icon: ShieldCheck,
},
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
// Nokta hiçbir yerde tek başına durmaz; yanındaki etiket dar ekranda sr-only
// olarak kalır (renk tek başına anlam taşımaz).
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = {
riskli: { nokta: "bg-red-500", icon: Rocket },
"az-riskli": { nokta: "bg-amber-500", icon: Scale },
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
};
const YILLAR = [2021, 2022, 2023, 2024, 2025];
type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
/** Deterministik trend: ilk ve son bilinen taban sıralaması kıyaslanır.
* Sıralama sayısı küçüldüyse bölüm zorlaşıyor demektir. */
function trendYonu(gecmis?: (number | null)[]): Trend | null {
const dolu = (gecmis ?? []).filter((s): s is number => s != null);
if (dolu.length < 2) return null;
const oran = dolu[dolu.length - 1] / dolu[0];
if (oran < 0.88) return "zorlasiyor";
if (oran > 1.14) return "kolaylasiyor";
return "stabil";
}
const TREND_GORUNUM: Record<
Trend,
{ icon: typeof Minus; etiket: string; renk: string }
> = {
zorlasiyor: {
icon: TrendingUp,
etiket: "Taban zorlaşıyor",
renk: "text-red-500",
},
kolaylasiyor: {
icon: TrendingDown,
etiket: "Taban rahatlıyor",
renk: "text-emerald-600",
},
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
};
/** Son 5 yılın taban sıralaması mini çizgisi (detay panelinde). */
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
(n): n is { yil: number; sira: number } => n.sira != null,
);
if (noktalar.length < 2) return null;
const W = 150;
const H = 40;
const min = Math.min(...noktalar.map((n) => n.sira));
const max = Math.max(...noktalar.map((n) => n.sira));
const aralik = Math.max(1, max - min);
// Küçük sıralama = daha zor bölüm = çizgide yukarıda
const nokta = (i: number, sira: number) => ({
x: 6 + (i / (noktalar.length - 1)) * (W - 12),
y: 8 + ((sira - min) / aralik) * (H - 16),
});
const path = noktalar
.map((n, i) => {
const { x, y } = nokta(i, n.sira);
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(" ");
const son = nokta(noktalar.length - 1, noktalar[noktalar.length - 1].sira);
// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
const SUTUN_SAYISI = 6;
/**
* Açık satırın detay bloğu: dilim/risk başlığı + gerekçe ve risk/trend
* kartları. /sonuc'taki girişsiz tadımlık satırı da (tadimlik-satiri.tsx)
* aynı bileşeni kullanır — "demo değil ürünün kendisi" hissi kopyayla değil
* tek kaynakla sağlanır.
*/
export function RaporSatirDetayi({
dilim,
risk,
unitur,
gerekce,
riskNotu,
trendOzeti,
}: {
dilim: string;
risk: RiskSeviyesi;
unitur?: string | null;
gerekce: string;
riskNotu: string;
trendOzeti: string;
}) {
const stil = RISK_STIL[risk];
return (
<div className="mt-2 flex items-center gap-2">
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-10 w-37.5"
role="img"
aria-label={`Taban sıralaması ${noktalar[0].yil}: ${noktalar[0].sira.toLocaleString("tr-TR")}, ${noktalar[noktalar.length - 1].yil}: ${noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}`}
>
<path
d={path}
fill="none"
stroke="oklch(0.623 0.188 259.8)"
strokeWidth={2}
strokeLinecap="round"
/>
<circle cx={son.x} cy={son.y} r={3} fill="oklch(0.623 0.188 259.8)" />
</svg>
<span className="text-xs text-slate-500">
{noktalar[0].yil}: ~{noktalar[0].sira.toLocaleString("tr-TR")}
<br />
{noktalar[noktalar.length - 1].yil}: ~
{noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}
</span>
</div>
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[dilim]} · {RISK_ETIKET[risk]}
</span>
{unitur ? <span>· {unitur}</span> : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{gerekce}</dd>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve Yapay Zeka yorumu
</dt>
<dd className="mt-1 text-slate-700">
{riskNotu} {trendOzeti}
</dd>
</div>
</dl>
</>
);
}
@@ -151,7 +122,7 @@ export function RaporListesi({
onHaritadaGor?: (il: string | null, sira: number) => void;
}) {
const [acik, setAcik] = useState<Set<number>>(new Set());
const satirRefs = useRef(new Map<number, HTMLLIElement>());
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
useEffect(() => {
@@ -174,15 +145,6 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
// Dilim dağılımı: liste hayal diliminden başladığı için ilk ekran hep
// kırmızı görünür; özet, dengenin sağlıklı olduğunu tek bakışta gösterir.
const dilimSayilari = rapor.tercihler.reduce(
(t, x) => {
t[x.dilim] = (t[x.dilim] ?? 0) + 1;
return t;
},
{} as Record<string, number>,
);
const iller = new Set(
rapor.tercihler
.map((t) => rapor.programlar[t.programId]?.il)
@@ -190,252 +152,232 @@ export function RaporListesi({
);
const tekIl = iller.size === 1 ? [...iller][0] : null;
const satir = (
t: RaporSonuc["tercihler"][number],
index: number,
) => {
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi);
const seri = raporSiraSerisi(p?.siraGecmisi);
const taban = p?.efektifSira ?? programEtkinSira(seri);
const acikMi = acik.has(t.sira);
const sira2024 = p?.siraGecmisi?.[3] ?? null;
const sira2025 = p?.siraGecmisi?.[4] ?? null;
// Son iki yıldan biri eksikse satır başındaki trend rozeti çelişkili
// okunur ("2025: —" ama "stabil") — rozet ancak iki yıl da doluysa çıkar;
// uzun vadeli trend detaydaki 2021–2025 çizgisinde durur.
const trendG =
trend && sira2024 != null && sira2025 != null
? TREND_GORUNUM[trend]
: null;
const devlet = p?.unitur === "DEVLET";
return (
<li
key={t.sira}
ref={(el) => {
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-200 hover:border-slate-300 hover:shadow-xs ${
analizKilitli ? "border-slate-200" : `border-l-4 ${stil.kenar}`
}`}
>
<button
type="button"
<Fragment key={t.sira}>
<TableRow
ref={(el) => {
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
onClick={() => tiklandi(t.sira)}
aria-expanded={acikMi}
className="grid min-h-16 w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 text-left xl:grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] xl:px-4"
className="cursor-pointer"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira}
</span>
<span className="flex min-w-0 items-center gap-2.5">
{p?.universite ? (
<UniLogo ad={p.universite} boy="sm" className="hidden sm:flex" />
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{p?.isim ?? t.programId}
<TableCell className="pl-4 pr-0">
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira}
</span>
<span className="block truncate text-xs text-slate-500">
{p?.universite}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
<span className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-slate-500 xl:hidden">
<span>2024: {sira2024?.toLocaleString("tr-TR") ?? "—"}</span>
<span>2025: {sira2025?.toLocaleString("tr-TR") ?? "—"}</span>
{trendG ? <span className={trendG.renk}>{trendG.etiket}</span> : null}
</span>
</span>
</span>
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
{sira2024?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
{sira2025?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden items-center gap-1.5 text-xs xl:flex">
{trendG ? (
<>
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
<span className={trendG.renk}>
{trend === "zorlasiyor"
? "Zorlaşıyor"
: trend === "kolaylasiyor"
? "Rahatlıyor"
: "Stabil"}
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p?.universite ? <UniLogo ad={p.universite} boy="sm" /> : null}
<span className="min-w-0 flex-1">
{p ? (
<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="block truncate text-sm font-semibold">
{t.programId}
</span>
)}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
{p?.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
) : null}
<span className="truncate">
{p?.universite ? (
<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>
) : null}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
</>
) : (
<span className="text-slate-400">Veri eksik</span>
)}
</span>
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={seri} />
</TableCell>
<TableCell className="text-xs font-medium">
{analizKilitli ? (
<>
<Lock className="size-3.5 text-slate-400" aria-hidden />
<span className="text-slate-500">Pakette</span>
</>
<span className="inline-flex items-center gap-1.5 text-slate-500">
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
<span className="sr-only sm:not-sr-only">Pakette</span>
</span>
) : (
<>
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
<span>{RISK_ETIKET[risk]}</span>
</>
<span className="inline-flex items-center gap-1.5">
<span
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
aria-hidden
/>
<span className="sr-only sm:not-sr-only">
{RISK_ETIKET[risk]}
</span>
</span>
)}
</span>
<ChevronDown
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
/>
</button>
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
aria-expanded={acikMi}
onClick={(e) => {
e.stopPropagation();
tiklandi(t.sira);
}}
className="inline-flex size-8 min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDown
className={`size-4 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
/>
<span className="sr-only">
{p?.isim ?? t.programId} — Yapay Zeka değerlendirmesi
</span>
</button>
</TableCell>
</TableRow>
{acikMi ? (
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={SUTUN_SAYISI}
className="whitespace-normal bg-slate-50/60 px-4 py-4"
>
{analizKilitli ? (
<>
<Lock className="size-3.5" aria-hidden />
<span className="font-medium">Kişisel analiz pakette</span>
</>
) : (
<>
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</span>
</>
)}
{p?.unitur ? <span>· {p.unitur}</span> : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
2021–2025 taban trendi
</dt>
<dd className="mt-1 text-slate-700">
{p?.siraGecmisi ? (
<TrendCizgisi gecmis={p.siraGecmisi} />
) : (
"Yeterli veri yok."
)}
</dd>
</div>
{analizKilitli ? (
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Yapay Zeka karar desteği
</dt>
<dd className="mt-1 text-sm text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel yerleşme
riskini paketle gör.
</dd>
</div>
) : (
<>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
<span className="font-medium">Kişisel analiz pakette</span>
{p?.unitur ? <span>· {p.unitur}</span> : null}
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve Yapay Zeka yorumu
</dt>
<dd className="mt-1 text-slate-700">
{t.riskNotu} {t.trendOzeti}
</dd>
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-3">
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Lock className="size-3.5" aria-hidden />
Yapay Zeka karar desteği
</p>
<p className="mt-1 text-sm text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel
yerleşme riskini paketle gör.
</p>
</div>
</>
) : (
<RaporSatirDetayi
dilim={t.dilim}
risk={risk}
unitur={p?.unitur}
gerekce={t.gerekce}
riskNotu={t.riskNotu}
trendOzeti={t.trendOzeti}
/>
)}
</dl>
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<MapPin className="size-3.5" aria-hidden />
Haritada gör
</button>
) : null}
</div>
{onHaritadaGor && p?.il ? (
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<MapPin className="size-3.5" aria-hidden />
Haritada gör
</button>
) : null}
</TableCell>
</TableRow>
) : null}
</li>
</Fragment>
);
};
return (
<div>
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
<p className="mt-1 text-xs text-slate-500">
Satıra dokunarak 2021–2025 trendini inceleyebilirsin.
</p>
</div>
{kilitIdx < rapor.tercihler.length ? (
<p className="text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs font-medium">
{(
[
["hayal", "bg-red-500"],
["dengeli", "bg-amber-500"],
["garanti", "bg-emerald-500"],
] as const
).map(([dilim, nokta]) => (
<span
key={dilim}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-700"
>
<span className={`size-2 rounded-full ${nokta}`} aria-hidden />
{dilimSayilari[dilim] ?? 0} {DILIM_ETIKET[dilim].toLocaleLowerCase("tr-TR")}
</span>
))}
<span className="text-slate-400">
Liste hayal diliminden başlar; kırmızı satırlar plana dahildir.
</span>
</div>
{kilitIdx < rapor.tercihler.length ? (
<p className="text-right text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null}
{tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Listenin 24 tercihi de tek şehirde (
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak
için birkaç farklı şehir eklemeyi düşünebilirsin.
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
birkaç farklı şehir eklemeyi düşünebilirsin.
</p>
) : null}
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
<span className="w-7">#</span>
<span>Program</span>
<span className="text-right">2024 taban</span>
<span className="text-right">2025 taban</span>
<span>Ham trend</span>
<span>Yapay Zeka risk</span>
<span className="w-4" />
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8 pl-4 pr-0">#</TableHead>
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead>
<span className="hidden sm:inline">Yapay Zeka </span>Risk
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-12 pr-4 text-right">
<span className="sr-only">Yapay Zeka değerlendirmesi</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rapor.tercihler.map((t, index) => (
<Fragment key={t.sira}>
{index === kilitIdx && kilitOverlay ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={SUTUN_SAYISI}
className="whitespace-normal border-y border-orange-200 bg-orange-50/70 p-5"
>
{kilitOverlay}
</TableCell>
</TableRow>
) : null}
{satir(t, index)}
</Fragment>
))}
</TableBody>
</Table>
</div>
<ol className="mt-2 flex flex-col gap-2">
{rapor.tercihler.map((t, index) => (
<Fragment key={t.sira}>
{index === kilitIdx && kilitOverlay ? (
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
{kilitOverlay}
</li>
) : null}
{satir(t, index)}
</Fragment>
))}
</ol>
</div>
);
}

View File

@@ -86,7 +86,7 @@ export function Sayfalama({
{sayfa > 1
? gecis(
sayfa - 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
<ChevronLeft className="size-4" aria-hidden />
Önceki
@@ -107,8 +107,8 @@ export function Sayfalama({
gecis(
n,
n === sayfa
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
? "inline-flex h-9 min-w-9 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-full border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
n,
{ aktif: n === sayfa },
)
@@ -117,7 +117,7 @@ export function Sayfalama({
{sayfa < toplamSayfa
? gecis(
sayfa + 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
Sonraki
<ChevronRight className="size-4" aria-hidden />

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -22,16 +22,14 @@ const buttonVariants = cva(
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
"h-9 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-12 gap-2 px-7 text-base has-data-[icon=inline-end]:pr-6 has-data-[icon=inline-start]:pl-6",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7",
"icon-lg": "size-12",
},
},
defaultVariants: {

View File

@@ -28,7 +28,7 @@ function PromptSuggestion({
<Button
variant={variant || "outline"}
size={size || "lg"}
className={cn("rounded-full", className)}
className={cn(className)}
{...props}
>
{children}
@@ -42,7 +42,7 @@ function PromptSuggestion({
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start rounded-xl py-2",
"w-full cursor-pointer justify-start py-2",
"hover:bg-accent",
className
)}
@@ -63,7 +63,7 @@ function PromptSuggestion({
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
"w-full cursor-pointer justify-start gap-0 py-2",
"hover:bg-accent",
className
)}

View File

@@ -8,6 +8,7 @@ import {
programEtkinSira,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import {
Table,
TableBody,
@@ -56,8 +57,8 @@ export function UniversiteProgramTablosu({
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
<TableHead className="w-24 text-right">
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -108,11 +109,18 @@ export function UniversiteProgramTablosu({
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="universite"
className="size-8 min-h-8 min-w-8"
/>
<span className="inline-flex items-center gap-1.5">
<ProgramNetleriButonu
programId={p.id}
isim={p.isim.trim()}
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu
program={p}
kaynak="universite"
className="size-8 min-h-8 min-w-8"
/>
</span>
</TableCell>
</TableRow>
);

View File

@@ -0,0 +1,54 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import { olay, type OlayAdi, type Ozellikler } from "@/lib/analitik";
/**
* Bir elemanın viewport'a ilk girişinde analitik olayı atan ref hook'u.
* Gösterim olayları (hazir_panel_goruntulendi, kilit_goruntulendi vb.)
* oturum başına en fazla 1 kez sayılmalı; tekilleştirme sessionStorage
* anahtarıyla yapılır. IntersectionObserver yoksa olay hiç atılmaz —
* "görüntülendi" ölçümünde görünürlüğü doğrulayamadığımız cihazı saymak
* veriyi şişirir.
*/
export function useGorunumOlayi<T extends HTMLElement = HTMLElement>(
ad: OlayAdi,
ozellikler?: Ozellikler,
/** Aynı olayın farklı yüzeyleri için tekilleştirme eki ("satir", "kart"). */
anahtarEki?: string,
): RefObject<T | null> {
const ref = useRef<T>(null);
// Özellikler her render sonrası tazelenir; observer effect'i yalnızca
// ad/anahtar değişiminde kurulur (deps'e obje koymamak için ref).
const ozelliklerRef = useRef(ozellikler);
useEffect(() => {
ozelliklerRef.current = ozellikler;
});
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const anahtar = `kolaytercih.olay.${ad}${anahtarEki ? `.${anahtarEki}` : ""}`;
try {
if (window.sessionStorage.getItem(anahtar)) return;
} catch {
// Storage engelliyse tekilleştirmeden vazgeç, olay yine atılabilir
}
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
io.disconnect();
try {
window.sessionStorage.setItem(anahtar, "1");
} catch {
// yut
}
olay(ad, ozelliklerRef.current);
});
io.observe(el);
return () => io.disconnect();
}, [ad, anahtarEki]);
return ref;
}

View File

@@ -3,6 +3,7 @@ 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 { RybbitIdentify } from "@/components/rybbit-identify";
import { SignOutButton } from "./sign-out-button";
@@ -29,6 +30,7 @@ export async function UserNav() {
return (
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
<RybbitIdentify kullaniciId={null} />
<ListeSenkron userId={null} />
<ListemButonu />
<Button
asChild
@@ -43,6 +45,7 @@ export async function UserNav() {
return (
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
<ListeSenkron userId={u.id} />
<ListemButonu />
<Link
href="/paket"