refactor: katalog feature'ı — sayfalar senkron kompozisyona geçti (Faz 2)
- 8 katalog bileşeni src/features/katalog/components/ altına taşındı; katalog-queries.ts (server-only) lib/katalog'un feature kapısı oldu - /bolumler ve /universiteler'in sayfa içi fetch+inline bileşenleri BolumKatalogu/UniversiteKatalogu feature bileşenlerine çıkarıldı - 6 sayfa async'ten senkron'a: params.then() + sayfada Suspense, skeleton'lar feature bileşeninin yanında (BolumIcerikSkeleton vb.) - generateStaticParams artık *-queries yerine bileşen dosyasının helper'larını kullanıyor (bolumStaticSlugs, uniSayfaStaticParams) - Davranış değişikliği yok; route durumları aynı (◐), 1917 statik sayfa Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
165
src/features/katalog/components/bolum-icerik.tsx
Normal file
165
src/features/katalog/components/bolum-icerik.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import {
|
||||
getAllBolumler,
|
||||
getAllBolumSlugs,
|
||||
getBolumBySlug,
|
||||
uniSlugFromAd,
|
||||
} from "../katalog-queries";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { SAYFA_BOYU } from "./sayfalama";
|
||||
import { BolumProgramListesi } from "./bolum-program-listesi";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
export function bolumToplamSayfa(programSayisi: number): number {
|
||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||
}
|
||||
|
||||
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
|
||||
export function bolumStaticSlugs(): string[] {
|
||||
return getAllBolumSlugs();
|
||||
}
|
||||
|
||||
// 2..N sayfaları; 1. sayfa taban rota (/bolum/[slug]) olduğundan üretilmez.
|
||||
export function bolumSayfaStaticParams(): { slug: string; no: string }[] {
|
||||
return getAllBolumler().flatMap((b) => {
|
||||
const toplam = bolumToplamSayfa(b.programSayisi);
|
||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||
slug: b.slug,
|
||||
no: String(i + 2),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||
const bolum = getBolumBySlug(slug);
|
||||
if (!bolum) return {};
|
||||
const { stats } = bolum;
|
||||
if (sayfa > 1) {
|
||||
return {
|
||||
title: `${bolum.ad} Taban Puanları 2026 (Sayfa ${sayfa})`,
|
||||
description: `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın 2025 yerleştirme verileri.`,
|
||||
alternates: { canonical: `/bolum/${slug}/sayfa/${sayfa}` },
|
||||
};
|
||||
}
|
||||
const enIyi =
|
||||
stats.minSira != null
|
||||
? ` En iyi taban sıralaması ${sayi(stats.minSira)}.`
|
||||
: "";
|
||||
return {
|
||||
title: `${bolum.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
|
||||
description: `${bolum.ad} 2025 yerleştirme verileri: ${stats.toplam} programın taban puanı ve sıralaması, ${stats.devlet} devlet / ${stats.vakif} vakıf.${enIyi} 2026 tercih rehberi.`,
|
||||
alternates: { canonical: `/bolum/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
const bolum = getBolumBySlug(slug);
|
||||
if (!bolum) notFound();
|
||||
|
||||
const tumProgramlar = [
|
||||
...bolum.lisansProgramlari,
|
||||
...bolum.onlisansProgramlari,
|
||||
];
|
||||
const toplamSayfa = bolumToplamSayfa(tumProgramlar.length);
|
||||
if (sayfa < 1 || sayfa > toplamSayfa) notFound();
|
||||
const ilkSayfa = sayfa === 1;
|
||||
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
{ name: "Bölümler", path: "/bolumler" },
|
||||
{ name: bolum.ad, path: `/bolum/${slug}` },
|
||||
...(ilkSayfa ? [] : [{ name: `Sayfa ${sayfa}`, path: buYol }]),
|
||||
])}
|
||||
/>
|
||||
|
||||
<nav
|
||||
aria-label="İçerik yolu"
|
||||
className="mb-5 flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Ana sayfa
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
<Link
|
||||
href="/bolumler"
|
||||
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Bölümler
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
<span className="text-foreground">
|
||||
{ilkSayfa ? bolum.ad : `Sayfa ${sayfa}`}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<header className="border-b pb-7 sm:pb-9">
|
||||
<h1 className="max-w-3xl font-heading text-3xl font-bold tracking-tight text-foreground sm:text-5xl">
|
||||
{bolum.ad}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
{sayi(tumProgramlar.length)} üniversite programının 2025 taban
|
||||
puanlarını, başarı sıralamalarını ve kontenjanlarını incele
|
||||
{!ilkSayfa ? ` — sayfa ${sayfa}` : ""}.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="universite-programlari" className="mt-8">
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<h2
|
||||
id="universite-programlari"
|
||||
className="font-heading text-xl font-bold text-foreground sm:text-2xl"
|
||||
>
|
||||
Üniversite programları
|
||||
</h2>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{sayi(tumProgramlar.length)} program
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BolumProgramListesi
|
||||
programlar={tumProgramlar.map((program) => ({
|
||||
...program,
|
||||
universiteSlug: uniSlugFromAd(program.universite),
|
||||
}))}
|
||||
bazAd={bolum.ad}
|
||||
tabanYol={`/bolum/${slug}`}
|
||||
sayfa={sayfa}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BolumIcerikSkeleton() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<Skeleton className="h-5 w-56" />
|
||||
<div className="border-b pb-7 sm:pb-9">
|
||||
<Skeleton className="mt-5 h-10 w-2/3 max-w-xl" />
|
||||
<Skeleton className="mt-3 h-5 w-full max-w-2xl" />
|
||||
</div>
|
||||
<div className="mt-8 space-y-3">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
115
src/features/katalog/components/bolum-listesi.tsx
Normal file
115
src/features/katalog/components/bolum-listesi.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { getAllBolumler, type BolumOzet } from "../katalog-queries";
|
||||
import { KATEGORILER } from "@/lib/kategoriler";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
|
||||
return (
|
||||
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bolumler.map((b) => (
|
||||
<li key={b.slug}>
|
||||
<Link
|
||||
href={`/bolum/${b.slug}`}
|
||||
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
|
||||
>
|
||||
<span className="group-hover:underline">{b.ad}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{sayi(b.programSayisi)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function KategoriGruplari({
|
||||
bolumler,
|
||||
seviye,
|
||||
}: {
|
||||
bolumler: BolumOzet[];
|
||||
seviye: "lisans" | "onlisans";
|
||||
}) {
|
||||
const kalanlar = new Set(bolumler.map((b) => b.slug));
|
||||
const gruplar = KATEGORILER.map((kategori) => {
|
||||
const uyeler = bolumler.filter((b) =>
|
||||
(b.kategoriler as readonly string[]).includes(kategori),
|
||||
);
|
||||
for (const b of uyeler) kalanlar.delete(b.slug);
|
||||
return { kategori: kategori as string, uyeler };
|
||||
}).filter((g) => g.uyeler.length > 0);
|
||||
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
|
||||
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
|
||||
|
||||
return (
|
||||
<>
|
||||
{gruplar.map(({ kategori, uyeler }) => (
|
||||
<section key={`${seviye}-${kategori}`} className="mt-8">
|
||||
<h3 className="font-heading text-lg font-bold text-slate-900">
|
||||
{kategori}
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||
{sayi(uyeler.length)} bölüm
|
||||
</span>
|
||||
</h3>
|
||||
<BolumLinkleri bolumler={uyeler} />
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BolumKatalogu() {
|
||||
const hepsi = getAllBolumler();
|
||||
const lisans = hepsi.filter((b) => b.lisans);
|
||||
const onlisans = hepsi.filter((b) => b.onlisans && !b.lisans);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
|
||||
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
|
||||
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu görürsün.
|
||||
Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri
|
||||
için rehber niteliğindedir.
|
||||
</p>
|
||||
|
||||
<PagePixelDivider seed={29} className="mt-8" />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Lisans Bölümleri (4 Yıllık)
|
||||
</h2>
|
||||
<KategoriGruplari bolumler={lisans} seviye="lisans" />
|
||||
</section>
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Önlisans Bölümleri (2 Yıllık — TYT)
|
||||
</h2>
|
||||
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BolumKataloguSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
|
||||
<div className="mt-8 space-y-4">
|
||||
<Skeleton className="h-8 w-72" />
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-6 w-full max-w-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
src/features/katalog/components/bolum-program-listesi.tsx
Normal file
286
src/features/katalog/components/bolum-program-listesi.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "./program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
type SiralamaAlani = "sira2025" | "puan2025" | "kontenjan2025";
|
||||
type SiralamaYonu = "artan" | "azalan";
|
||||
export type BolumProgrami = Program & { universiteSlug: string | null };
|
||||
|
||||
const ALAN_ETIKETLERI: Record<SiralamaAlani, string> = {
|
||||
sira2025: "Sıralama",
|
||||
puan2025: "Puan",
|
||||
kontenjan2025: "Kontenjan",
|
||||
};
|
||||
|
||||
function SiralamaDugmesi({
|
||||
alan,
|
||||
aktifAlan,
|
||||
yon,
|
||||
onSirala,
|
||||
}: {
|
||||
alan: SiralamaAlani;
|
||||
aktifAlan: SiralamaAlani | null;
|
||||
yon: SiralamaYonu;
|
||||
onSirala: (alan: SiralamaAlani) => void;
|
||||
}) {
|
||||
const aktif = alan === aktifAlan;
|
||||
const etiket = ALAN_ETIKETLERI[alan];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSirala(alan)}
|
||||
aria-label={`${etiket}: ${
|
||||
aktif && yon === "artan" ? "azalan" : "artan"
|
||||
} sırala`}
|
||||
aria-pressed={aktif}
|
||||
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>
|
||||
<ChevronUp
|
||||
className={`size-3 ${
|
||||
aktif && yon === "artan"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/40"
|
||||
}`}
|
||||
strokeWidth={aktif && yon === "artan" ? 3 : 2}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={`size-3 ${
|
||||
aktif && yon === "azalan"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/40"
|
||||
}`}
|
||||
strokeWidth={aktif && yon === "azalan" ? 3 : 2}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function BolumProgramListesi({
|
||||
programlar,
|
||||
bazAd,
|
||||
tabanYol,
|
||||
sayfa,
|
||||
}: {
|
||||
/** Bölümün TÜM programları — sıralama liste geneline uygulanır. */
|
||||
programlar: BolumProgrami[];
|
||||
bazAd: string;
|
||||
tabanYol: string;
|
||||
sayfa: number;
|
||||
}) {
|
||||
const [aktifAlan, setAktifAlan] = useState<SiralamaAlani | null>(null);
|
||||
const [yon, setYon] = useState<SiralamaYonu>("artan");
|
||||
// Sıralama aktifken URL değişmeden istemci tarafında gezilen sayfa
|
||||
const [istemciSayfa, setIstemciSayfa] = useState(1);
|
||||
const listeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toplamSayfa = Math.max(1, Math.ceil(programlar.length / SAYFA_BOYU));
|
||||
|
||||
const siraliProgramlar = useMemo(() => {
|
||||
if (!aktifAlan) return programlar;
|
||||
|
||||
return programlar
|
||||
.map((program, index) => ({ program, index }))
|
||||
.sort((a, b) => {
|
||||
const aDeger = a.program[aktifAlan];
|
||||
const bDeger = b.program[aktifAlan];
|
||||
if (aDeger == null && bDeger == null) return a.index - b.index;
|
||||
if (aDeger == null) return 1;
|
||||
if (bDeger == null) return -1;
|
||||
|
||||
const fark = aDeger - bDeger;
|
||||
return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark;
|
||||
})
|
||||
.map(({ program }) => program);
|
||||
}, [aktifAlan, programlar, yon]);
|
||||
|
||||
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
|
||||
const gosterilenProgramlar = useMemo(
|
||||
() =>
|
||||
siraliProgramlar.slice(
|
||||
(gosterilenSayfa - 1) * SAYFA_BOYU,
|
||||
gosterilenSayfa * SAYFA_BOYU,
|
||||
),
|
||||
[siraliProgramlar, gosterilenSayfa],
|
||||
);
|
||||
|
||||
function sirala(alan: SiralamaAlani) {
|
||||
setIstemciSayfa(1);
|
||||
if (aktifAlan === alan) {
|
||||
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
||||
return;
|
||||
}
|
||||
setAktifAlan(alan);
|
||||
setYon("artan");
|
||||
}
|
||||
|
||||
function istemciSayfaDegistir(n: number) {
|
||||
setIstemciSayfa(n);
|
||||
listeRef.current?.scrollIntoView({ block: "start" });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={listeRef}
|
||||
className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Üniversite</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right lg:table-cell">
|
||||
<SiralamaDugmesi
|
||||
alan="puan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right sm:table-cell">
|
||||
<SiralamaDugmesi
|
||||
alan="kontenjan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<SiralamaDugmesi
|
||||
alan="sira2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-24 text-right">
|
||||
<span className="sr-only">Netler ve listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{gosterilenProgramlar.map((p) => {
|
||||
const uniAd = trBaslikDuzeni(p.universite);
|
||||
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{p.universiteSlug ? (
|
||||
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
{p.universiteSlug ? (
|
||||
<Link
|
||||
href={`/universite/${p.universiteSlug}`}
|
||||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{uniAd}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{uniAd}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 flex min-w-0 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" : trBaslikDuzeni(p.unitur)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.il ? trBaslikDuzeni(p.il) : null}
|
||||
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
|
||||
</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 lg:table-cell">
|
||||
{p.puan2025 != null
|
||||
? p.puan2025.toFixed(2).replace(".", ",")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
|
||||
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ProgramTabanTrendi program={p} />
|
||||
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-3 text-right">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Sayfalama
|
||||
tabanYol={tabanYol}
|
||||
sayfa={gosterilenSayfa}
|
||||
toplamSayfa={toplamSayfa}
|
||||
onSayfa={aktifAlan ? istemciSayfaDegistir : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
764
src/features/katalog/components/katalog-arama.tsx
Normal file
764
src/features/katalog/components/katalog-arama.tsx
Normal file
@@ -0,0 +1,764 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
|
||||
import { RehberKapak } from "@/components/rehber-kapak";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
|
||||
import type { RehberOzet } from "@/lib/rehber";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
sonucaGitIste,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpenText,
|
||||
Building2,
|
||||
GraduationCap,
|
||||
Search,
|
||||
SquarePen,
|
||||
Trophy,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
|
||||
type BolumSonucu = {
|
||||
href: string;
|
||||
ad: string;
|
||||
programSayisi: number;
|
||||
enIyiSira: number | null;
|
||||
seviye: string;
|
||||
};
|
||||
|
||||
type UniversiteSonucu = {
|
||||
href: string;
|
||||
ad: string;
|
||||
il: string | null;
|
||||
tur: string | null;
|
||||
programSayisi: number;
|
||||
fakulteSayisi: number;
|
||||
};
|
||||
|
||||
type AramaSonuclari = {
|
||||
bolumler: BolumSonucu[];
|
||||
universiteler: UniversiteSonucu[];
|
||||
};
|
||||
|
||||
const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
|
||||
const REHBER_SONUC_SINIRI = 4;
|
||||
const sayi = (deger: number) => deger.toLocaleString("tr-TR");
|
||||
|
||||
const KESFET_TONLARI = {
|
||||
slate: {
|
||||
pencere: "border-slate-200/80 bg-white",
|
||||
nokta: "bg-slate-300",
|
||||
rozet: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
cizgi: "bg-slate-200/90",
|
||||
},
|
||||
sky: {
|
||||
pencere: "border-sky-200/70 bg-white",
|
||||
nokta: "bg-sky-300",
|
||||
rozet: "border-sky-200 bg-sky-50 text-sky-600",
|
||||
cizgi: "bg-sky-100",
|
||||
},
|
||||
amber: {
|
||||
pencere: "border-amber-200/70 bg-white",
|
||||
nokta: "bg-amber-300",
|
||||
rozet: "border-amber-200 bg-amber-50 text-amber-600",
|
||||
cizgi: "bg-amber-100",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
const router = useRouter();
|
||||
const profil = useTercihProfili();
|
||||
const [acik, setAcik] = useState(false);
|
||||
|
||||
function siralamaGirVeyaSonucaGit() {
|
||||
// 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("");
|
||||
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [macMi, setMacMi] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const kaydirmaRef = useRef<HTMLDivElement>(null);
|
||||
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
|
||||
// Kapanışta gönderilecek son arama özeti (bkz. aşağıdaki fetch efekti)
|
||||
const sonAramaRef = useRef<{
|
||||
sorgu_uzunlugu: number;
|
||||
sonuc_sayisi: number;
|
||||
} | null>(null);
|
||||
const temizSorgu = sorgu.trim();
|
||||
const aramaAktif = temizSorgu.length >= 2;
|
||||
|
||||
const kenarBlurGuncelle = useCallback(() => {
|
||||
const alan = kaydirmaRef.current;
|
||||
if (!alan) return;
|
||||
const ust = alan.scrollTop > 4;
|
||||
const alt = alan.scrollTop + alan.clientHeight < alan.scrollHeight - 4;
|
||||
setKenarBlur((onceki) =>
|
||||
onceki.ust === ust && onceki.alt === alt ? onceki : { ust, alt },
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Rehber özetleri build sırasında server'dan prop olarak gelir (~10 yazı);
|
||||
// arama client'ta yapılır — prod imajında content/ dizini bulunmaz.
|
||||
const rehberSonuclari = useMemo(() => {
|
||||
if (!aramaAktif) return [];
|
||||
const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80);
|
||||
return rehberler
|
||||
.filter((yazi) =>
|
||||
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
eslesmePuani(a.baslik, normalSorgu) -
|
||||
eslesmePuani(b.baslik, normalSorgu),
|
||||
)
|
||||
.slice(0, REHBER_SONUC_SINIRI);
|
||||
}, [aramaAktif, temizSorgu, rehberler]);
|
||||
|
||||
const sonucVar =
|
||||
sonuclar.bolumler.length > 0 ||
|
||||
sonuclar.universiteler.length > 0 ||
|
||||
rehberSonuclari.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const platform =
|
||||
(navigator as { userAgentData?: { platform?: string } }).userAgentData
|
||||
?.platform ??
|
||||
navigator.platform ??
|
||||
"";
|
||||
setMacMi(/mac|iphone|ipad/i.test(platform));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function kisayolDinle(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
modalDegisti(!acik);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", kisayolDinle);
|
||||
return () => window.removeEventListener("keydown", kisayolDinle);
|
||||
}, [acik]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!acik || !aramaAktif) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const zamanlayici = window.setTimeout(async () => {
|
||||
setYukleniyor(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) throw new Error("Arama başarısız");
|
||||
const veri = (await response.json()) as AramaSonuclari;
|
||||
setSonuclar(veri);
|
||||
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
|
||||
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
|
||||
sonAramaRef.current = {
|
||||
sorgu_uzunlugu: temizSorgu.length,
|
||||
sonuc_sayisi: veri.bolumler.length + veri.universiteler.length,
|
||||
};
|
||||
} catch (hata) {
|
||||
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setYukleniyor(false);
|
||||
}
|
||||
}, 160);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(zamanlayici);
|
||||
controller.abort();
|
||||
};
|
||||
}, [acik, aramaAktif, temizSorgu]);
|
||||
|
||||
// Sonuçlar, logolar ve kapak görselleri yüklendikçe scrollHeight değişir;
|
||||
// kenar blur görünürlüğünü scroll olayına ek olarak boyut değişiminde de tazele.
|
||||
useEffect(() => {
|
||||
if (!acik) return;
|
||||
const alan = kaydirmaRef.current;
|
||||
if (!alan) return;
|
||||
kenarBlurGuncelle();
|
||||
const gozlemci = new ResizeObserver(kenarBlurGuncelle);
|
||||
gozlemci.observe(alan);
|
||||
for (const cocuk of alan.children) gozlemci.observe(cocuk);
|
||||
return () => gozlemci.disconnect();
|
||||
}, [acik, aramaAktif, yukleniyor, sonucVar, kenarBlurGuncelle]);
|
||||
|
||||
// Temizlik kapanışta değil açılışta yapılır: kapanış animasyonu sırasında
|
||||
// içerik "Keşfet" ekranına zıplamaz, modal her açılışta temiz başlar.
|
||||
// (X / sonuç linki `kapat` ile kapatır; orada temizlik yapılmadığı için
|
||||
// eski sorgu, bayat sonuçlar ve yarıda kesilen fetch'in takılı kalan
|
||||
// yükleniyor iskeleti bir sonraki açılışa taşınıyordu.)
|
||||
function modalDegisti(yeniAcik: boolean) {
|
||||
setAcik(yeniAcik);
|
||||
if (yeniAcik) {
|
||||
setSorgu("");
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
setYukleniyor(false);
|
||||
setKenarBlur({ ust: false, alt: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Arama oturumu kapandığında tek bir olay — hiç arama yapılmadıysa sessiz
|
||||
useEffect(() => {
|
||||
if (acik) return;
|
||||
const son = sonAramaRef.current;
|
||||
if (!son) return;
|
||||
sonAramaRef.current = null;
|
||||
olay("katalog_arama", son);
|
||||
}, [acik]);
|
||||
|
||||
function kapat() {
|
||||
setAcik(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* lg altında logo + sağ küme ile çakışır (absolute); o aralıkta gizli */}
|
||||
<nav className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 lg:flex">
|
||||
{profil ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
|
||||
className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"
|
||||
>
|
||||
<Trophy className="size-4 shrink-0 text-orange-500" aria-hidden />
|
||||
<span className="font-semibold tracking-tight text-slate-900">
|
||||
<span className="tabular-nums">
|
||||
{profil.sira.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<span className="font-medium text-slate-500">
|
||||
{" "}
|
||||
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex max-w-0 items-center gap-1 overflow-hidden rounded-full bg-slate-100 px-0 py-1.5 text-xs font-medium text-slate-500 opacity-0 transition-[max-width,padding,opacity,background-color,color] duration-200 group-hover/profil:max-w-28 group-hover/profil:bg-white group-hover/profil:px-2.5 group-hover/profil:text-slate-900 group-hover/profil:opacity-100 group-focus-visible/profil:max-w-28 group-focus-visible/profil:bg-white group-focus-visible/profil:px-2.5 group-focus-visible/profil:text-slate-900 group-focus-visible/profil:opacity-100"
|
||||
>
|
||||
<SquarePen className="size-3 shrink-0" aria-hidden />
|
||||
Değiştir
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={siralamaGirVeyaSonucaGit}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Trophy className="size-4 text-orange-500" aria-hidden />
|
||||
Sıralamanı gir
|
||||
</button>
|
||||
<Link
|
||||
href="/#nasil-calisir"
|
||||
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
Nasıl çalışır?
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => modalDegisti(true)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full bg-slate-900 px-5 py-2 text-white transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
|
||||
>
|
||||
<Search className="size-4" aria-hidden />
|
||||
Ara
|
||||
<KbdGroup aria-hidden className="ml-1">
|
||||
<Kbd className="bg-white/15 text-white/85">
|
||||
{macMi ? "⌘" : "Ctrl"}
|
||||
</Kbd>
|
||||
<Kbd className="bg-white/15 text-white/85">K</Kbd>
|
||||
</KbdGroup>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* lg altında 2. satır: sıralama + ara (header flex-wrap + order-3) */}
|
||||
<div className="order-3 flex w-full basis-full items-center gap-1.5 lg:hidden">
|
||||
{profil ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
|
||||
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm"
|
||||
>
|
||||
<Trophy
|
||||
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate tracking-tight">
|
||||
<span className="tabular-nums">
|
||||
{profil.sira.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<span className="font-medium text-slate-500">
|
||||
{" "}
|
||||
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 sm:text-xs">
|
||||
<SquarePen className="size-2.5 sm:size-3" aria-hidden />
|
||||
Değiştir
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={siralamaGirVeyaSonucaGit}
|
||||
aria-label="Başarı sıralamanı gir"
|
||||
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm"
|
||||
>
|
||||
<Trophy
|
||||
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">Sıralama gir</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => modalDegisti(true)}
|
||||
aria-label="Bölüm, üniversite veya rehber ara"
|
||||
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:size-12"
|
||||
>
|
||||
<Search className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Dialog open={acik} onOpenChange={modalDegisti}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="max-h-[min(720px,calc(100dvh-2rem))] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden rounded-2xl bg-white p-0 duration-200 sm:max-w-2xl motion-reduce:data-open:zoom-in-100 motion-reduce:data-closed:zoom-out-100"
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Bölüm, üniversite ve rehber ara
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
YKS bölümlerini, üniversiteleri ve rehber yazılarını tek yerden
|
||||
ara.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex items-center border-b border-slate-200 px-4 sm:px-5">
|
||||
<Search
|
||||
className="mr-3 size-5 shrink-0 text-slate-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={sorgu}
|
||||
onChange={(event) => {
|
||||
const yeniSorgu = event.target.value;
|
||||
setSorgu(yeniSorgu);
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
setYukleniyor(yeniSorgu.trim().length >= 2);
|
||||
}}
|
||||
placeholder="Bölüm, üniversite veya rehber ara..."
|
||||
aria-label="Bölüm, üniversite veya rehber ara"
|
||||
autoComplete="off"
|
||||
className="h-16 min-w-0 flex-1 bg-transparent text-base text-slate-950 outline-none placeholder:text-slate-400 sm:h-18 sm:text-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={kapat}
|
||||
aria-label="Aramayı kapat"
|
||||
className="flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0">
|
||||
<div
|
||||
ref={kaydirmaRef}
|
||||
onScroll={kenarBlurGuncelle}
|
||||
className="h-full max-h-full min-h-72 overflow-y-auto overscroll-contain p-3 sm:p-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
{!aramaAktif ? (
|
||||
<BaslangicIcerigi onSec={kapat} />
|
||||
) : yukleniyor ? (
|
||||
<AramaIskeleti />
|
||||
) : sonucVar ? (
|
||||
<div className="space-y-5">
|
||||
{sonuclar.bolumler.length > 0 ? (
|
||||
<SonucGrubu
|
||||
baslik="Bölümler"
|
||||
ikon={<GraduationCap className="size-4" aria-hidden />}
|
||||
>
|
||||
{sonuclar.bolumler.map((bolum) => (
|
||||
<SonucLinki
|
||||
key={bolum.href}
|
||||
href={bolum.href}
|
||||
baslik={bolum.ad}
|
||||
meta={`${bolum.seviye} · ${sayi(bolum.programSayisi)} program`}
|
||||
detay={
|
||||
bolum.enIyiSira
|
||||
? `En iyi sıra ${sayi(bolum.enIyiSira)}`
|
||||
: "Sıralama verisi yok"
|
||||
}
|
||||
onSec={kapat}
|
||||
/>
|
||||
))}
|
||||
</SonucGrubu>
|
||||
) : null}
|
||||
|
||||
{sonuclar.universiteler.length > 0 ? (
|
||||
<SonucGrubu
|
||||
baslik="Üniversiteler"
|
||||
ikon={<Building2 className="size-4" aria-hidden />}
|
||||
>
|
||||
{sonuclar.universiteler.map((universite) => (
|
||||
<SonucLinki
|
||||
key={universite.href}
|
||||
href={universite.href}
|
||||
gorsel={
|
||||
<UniLogo
|
||||
slug={universite.href.split("/").pop() ?? ""}
|
||||
ad={universite.ad}
|
||||
boy="sm"
|
||||
/>
|
||||
}
|
||||
baslik={universite.ad}
|
||||
meta={
|
||||
[universite.il, universite.tur]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Üniversite"
|
||||
}
|
||||
detay={`${sayi(universite.programSayisi)} program · ${sayi(universite.fakulteSayisi)} fakülte`}
|
||||
onSec={kapat}
|
||||
/>
|
||||
))}
|
||||
</SonucGrubu>
|
||||
) : null}
|
||||
|
||||
{rehberSonuclari.length > 0 ? (
|
||||
<SonucGrubu
|
||||
baslik="Rehber yazıları"
|
||||
ikon={<BookOpenText className="size-4" aria-hidden />}
|
||||
>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{rehberSonuclari.map((yazi) => (
|
||||
<RehberSonucKarti
|
||||
key={yazi.slug}
|
||||
yazi={yazi}
|
||||
onSec={kapat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SonucGrubu>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-64 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-500">
|
||||
<Search className="size-5" aria-hidden />
|
||||
</div>
|
||||
<p className="mt-4 font-medium text-slate-900">
|
||||
"{temizSorgu}" için sonuç bulunamadı
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm leading-6 text-slate-500">
|
||||
Bölüm, üniversite veya rehber konusunu farklı yazarak tekrar
|
||||
dene.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 h-14 transition-opacity duration-200 ${kenarBlur.ust ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<ProgressiveBlur
|
||||
position="top"
|
||||
backgroundColor="#ffffff"
|
||||
height="56px"
|
||||
blurAmount="4px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-x-0 bottom-0 h-14 transition-opacity duration-200 ${kenarBlur.alt ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<ProgressiveBlur
|
||||
position="bottom"
|
||||
backgroundColor="#ffffff"
|
||||
height="56px"
|
||||
blurAmount="4px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-200 bg-slate-50/80 px-4 py-3 text-xs text-slate-500 sm:px-5">
|
||||
<span>En az 2 harf yazarak ara</span>
|
||||
<span className="hidden items-center gap-1.5 sm:flex">
|
||||
<Kbd>Esc</Kbd> ile kapat
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BaslangicIcerigi({ onSec }: { onSec: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="px-1 pb-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Keşfet
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<KesfetLinki
|
||||
href="/bolumler"
|
||||
ikon={GraduationCap}
|
||||
baslik="Tüm bölümler"
|
||||
kisaBaslik="Bölümler"
|
||||
aciklama="Lisans · önlisans"
|
||||
ton="slate"
|
||||
onSec={onSec}
|
||||
/>
|
||||
<KesfetLinki
|
||||
href="/universiteler"
|
||||
ikon={Building2}
|
||||
baslik="Üniversiteler"
|
||||
kisaBaslik="Üniversite"
|
||||
aciklama="Devlet · vakıf"
|
||||
ton="sky"
|
||||
onSec={onSec}
|
||||
/>
|
||||
<KesfetLinki
|
||||
href="/rehber"
|
||||
ikon={BookOpenText}
|
||||
baslik="Tercih rehberi"
|
||||
kisaBaslik="Rehber"
|
||||
aciklama="Sorularına yanıt"
|
||||
ton="amber"
|
||||
onSec={onSec}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex gap-3 rounded-2xl bg-slate-50 px-3.5 py-3.5 sm:px-4">
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-white text-slate-400 shadow-[0_0_0_1px_rgba(15,23,42,0.06)]">
|
||||
<Search className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold tracking-tight text-slate-900">
|
||||
Neyi merak ediyorsun?
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500 sm:text-sm sm:leading-6">
|
||||
“Psikoloji” veya “Boğaziçi” yaz; eşleşen sayfaları ve temel
|
||||
bilgilerini burada önizle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AramaFunnelKarti onSec={onSec} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KesfetLinki({
|
||||
href,
|
||||
ikon: Ikon,
|
||||
baslik,
|
||||
kisaBaslik,
|
||||
aciklama,
|
||||
ton,
|
||||
onSec,
|
||||
}: {
|
||||
href: string;
|
||||
ikon: typeof GraduationCap;
|
||||
baslik: string;
|
||||
kisaBaslik: string;
|
||||
aciklama: string;
|
||||
ton: keyof typeof KESFET_TONLARI;
|
||||
onSec: () => void;
|
||||
}) {
|
||||
const palet = KESFET_TONLARI[ton];
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onSec}
|
||||
className="group/kesfet flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white transition-transform duration-150 ease-out active:scale-[0.97]"
|
||||
>
|
||||
{/* Mini tarayıcı — sekme içinde tek büyük ikon, alta doğru maske ile solar */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative h-18 overflow-hidden bg-slate-50/80 sm:h-22"
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-x-2 -bottom-3 top-2 flex flex-col overflow-hidden rounded-t-xl border border-b-0 sm:inset-x-2.5 sm:top-2.5 ${palet.pencere}`}
|
||||
>
|
||||
<div className="flex h-3.5 shrink-0 items-center gap-1 border-b border-slate-100/90 bg-slate-50/90 px-2">
|
||||
<span className={`size-1.5 rounded-full ${palet.nokta}`} />
|
||||
<span className={`size-1.5 rounded-full ${palet.nokta} opacity-65`} />
|
||||
<span className={`size-1.5 rounded-full ${palet.nokta} opacity-35`} />
|
||||
<span className="ml-1.5 h-1.5 max-w-[55%] flex-1 rounded-full bg-white shadow-[0_0_0_1px_rgba(15,23,42,0.06)]" />
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div className="absolute inset-x-0 top-0 flex flex-col items-center gap-1.5 pt-2 sm:gap-2 sm:pt-2.5">
|
||||
<span
|
||||
className={`flex size-9 items-center justify-center rounded-xl border shadow-[0_6px_12px_-6px_rgba(15,23,42,0.18)] transition-transform duration-200 ease-out group-hover/kesfet:-translate-y-0.5 sm:size-10 ${palet.rozet}`}
|
||||
>
|
||||
<Ikon className="size-4.5 sm:size-5" strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className={`h-1.5 w-2/5 rounded-full ${palet.cizgi}`} />
|
||||
<span className="h-1.5 w-1/4 rounded-full bg-slate-100" />
|
||||
</div>
|
||||
<ProgressiveBlur
|
||||
position="bottom"
|
||||
backgroundColor="#ffffff"
|
||||
height="45%"
|
||||
blurAmount="2px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-white to-transparent" />
|
||||
</div>
|
||||
|
||||
<span className="min-w-0 px-2.5 pb-3 pt-2 text-center sm:px-3.5 sm:pb-3.5 sm:pt-2.5 sm:text-left">
|
||||
<span className="block text-[12px] font-semibold leading-tight tracking-tight text-slate-900 sm:text-sm">
|
||||
<span className="sm:hidden">{kisaBaslik}</span>
|
||||
<span className="hidden sm:inline">{baslik}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-snug text-slate-500 sm:text-xs">
|
||||
{aciklama}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SonucGrubu({
|
||||
baslik,
|
||||
ikon,
|
||||
children,
|
||||
}: {
|
||||
baslik: string;
|
||||
ikon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||
{ikon}
|
||||
{baslik}
|
||||
</h3>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SonucLinki({
|
||||
href,
|
||||
baslik,
|
||||
meta,
|
||||
detay,
|
||||
gorsel,
|
||||
onSec,
|
||||
}: {
|
||||
href: string;
|
||||
baslik: string;
|
||||
meta: string;
|
||||
detay: string;
|
||||
gorsel?: React.ReactNode;
|
||||
onSec: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onSec}
|
||||
className="group flex items-center gap-3 rounded-xl px-3 py-3 transition-colors duration-150 hover:bg-slate-100"
|
||||
>
|
||||
{gorsel}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-slate-900">
|
||||
{baslik}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-slate-500">
|
||||
{meta}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-right text-xs text-slate-400 sm:block">
|
||||
{detay}
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="size-4 shrink-0 text-slate-300 transition-[color,transform] duration-150 group-hover:translate-x-0.5 group-hover:text-slate-600"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RehberSonucKarti({
|
||||
yazi,
|
||||
onSec,
|
||||
}: {
|
||||
yazi: RehberOzet;
|
||||
onSec: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/rehber/${yazi.slug}`}
|
||||
onClick={onSec}
|
||||
className="group overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 transition-[border-color,background-color,transform] duration-150 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.99]"
|
||||
>
|
||||
<RehberKapak baslik={yazi.baslik} compact className="rounded-lg" />
|
||||
<span className="block px-2 pb-1.5 pt-2.5">
|
||||
<span className="line-clamp-2 text-xs leading-5 text-slate-600">
|
||||
{yazi.aciklama}
|
||||
</span>
|
||||
<span className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-primary">
|
||||
Yazıyı oku
|
||||
<ArrowRight
|
||||
className="size-3.5 transition-transform duration-150 group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AramaIskeleti() {
|
||||
return (
|
||||
<div className="space-y-2 px-2 pt-2" aria-label="Aranıyor">
|
||||
{[0, 1, 2, 3].map((sira) => (
|
||||
<div
|
||||
key={sira}
|
||||
className="flex h-16 animate-pulse items-center rounded-xl bg-slate-100 px-3 motion-reduce:animate-none"
|
||||
>
|
||||
<div className="h-4 w-2/5 rounded bg-slate-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
src/features/katalog/components/program-netleri.tsx
Normal file
221
src/features/katalog/components/program-netleri.tsx
Normal 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 "@/types/yokatlas";
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
130
src/features/katalog/components/sayfalama.tsx
Normal file
130
src/features/katalog/components/sayfalama.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
/** Programatik tablo sayfaları için satır dilimi boyutu. */
|
||||
export const SAYFA_BOYU = 50;
|
||||
|
||||
/**
|
||||
* Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in
|
||||
* kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek
|
||||
* <Link>, Google her sayfayı ayrı statik URL olarak tarar.
|
||||
*
|
||||
* `onSayfa` verilirse Link yerine aynı görünümde butonlar render edilir —
|
||||
* istemci tarafında sıralanmış listelerin sayfalaması URL değiştirmeden
|
||||
* bu geri çağrıyla ilerler.
|
||||
*/
|
||||
export function Sayfalama({
|
||||
tabanYol,
|
||||
sayfa,
|
||||
toplamSayfa,
|
||||
sorgu,
|
||||
onSayfa,
|
||||
}: {
|
||||
tabanYol: string;
|
||||
sayfa: number;
|
||||
toplamSayfa: number;
|
||||
sorgu?: string;
|
||||
onSayfa?: (n: number) => void;
|
||||
}) {
|
||||
if (toplamSayfa <= 1) return null;
|
||||
const yol = (n: number) => {
|
||||
const path = n === 1 ? tabanYol : `${tabanYol}/sayfa/${n}`;
|
||||
return sorgu ? `${path}?${sorgu}` : path;
|
||||
};
|
||||
const gecis = (
|
||||
n: number,
|
||||
className: string,
|
||||
icerik: ReactNode,
|
||||
ozellikler?: { rel?: string; aktif?: boolean },
|
||||
) =>
|
||||
onSayfa ? (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onSayfa(n)}
|
||||
aria-current={ozellikler?.aktif ? "page" : undefined}
|
||||
className={`${className} cursor-pointer`}
|
||||
>
|
||||
{icerik}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
key={n}
|
||||
href={yol(n)}
|
||||
rel={ozellikler?.rel}
|
||||
aria-current={ozellikler?.aktif ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
{icerik}
|
||||
</Link>
|
||||
);
|
||||
|
||||
// 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son
|
||||
const numaralar: (number | "...")[] = [];
|
||||
if (toplamSayfa <= 7) {
|
||||
for (let n = 1; n <= toplamSayfa; n++) numaralar.push(n);
|
||||
} else {
|
||||
numaralar.push(1);
|
||||
if (sayfa > 3) numaralar.push("...");
|
||||
for (
|
||||
let n = Math.max(2, sayfa - 1);
|
||||
n <= Math.min(toplamSayfa - 1, sayfa + 1);
|
||||
n++
|
||||
) {
|
||||
numaralar.push(n);
|
||||
}
|
||||
if (sayfa < toplamSayfa - 2) numaralar.push("...");
|
||||
numaralar.push(toplamSayfa);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sayfalama"
|
||||
className="mt-6 flex flex-wrap items-center justify-center gap-1.5"
|
||||
>
|
||||
{sayfa > 1
|
||||
? gecis(
|
||||
sayfa - 1,
|
||||
"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
|
||||
</>,
|
||||
{ rel: "prev" },
|
||||
)
|
||||
: null}
|
||||
{numaralar.map((n, i) =>
|
||||
n === "..." ? (
|
||||
<span
|
||||
key={`bosluk-${i}`}
|
||||
className="px-1.5 text-sm text-slate-400"
|
||||
aria-hidden
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
gecis(
|
||||
n,
|
||||
n === sayfa
|
||||
? "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 },
|
||||
)
|
||||
),
|
||||
)}
|
||||
{sayfa < toplamSayfa
|
||||
? gecis(
|
||||
sayfa + 1,
|
||||
"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 />
|
||||
</>,
|
||||
{ rel: "next" },
|
||||
)
|
||||
: null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
316
src/features/katalog/components/universite-icerik.tsx
Normal file
316
src/features/katalog/components/universite-icerik.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import {
|
||||
getAllUniversiteler,
|
||||
getAllUniversiteSlugs,
|
||||
getUniversiteBySlug,
|
||||
bolumSlugFromIsim,
|
||||
} from "../katalog-queries";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { UniversiteKonumHaritasi } from "./universite-konum-haritasi";
|
||||
import { UniversiteProgramTablosu } from "./universite-program-tablosu";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { uniLogoYolu } from "@/lib/uni-logolar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
export function uniToplamSayfa(programSayisi: number): number {
|
||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||
}
|
||||
|
||||
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
|
||||
export function uniStaticSlugs(): string[] {
|
||||
return getAllUniversiteSlugs();
|
||||
}
|
||||
|
||||
// 2..N sayfaları; 1. sayfa taban rota (/universite/[slug]) olduğundan üretilmez.
|
||||
export function uniSayfaStaticParams(): { slug: string; no: string }[] {
|
||||
return getAllUniversiteler().flatMap((u) => {
|
||||
const toplam = uniToplamSayfa(u.programSayisi);
|
||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||
slug: u.slug,
|
||||
no: String(i + 2),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||
const uni = getUniversiteBySlug(slug);
|
||||
if (!uni) return {};
|
||||
if (sayfa > 1) {
|
||||
return {
|
||||
title: `${uni.ad} Taban Puanları 2026 (Sayfa ${sayfa})`,
|
||||
description: `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın 2025 yerleştirme verileri.`,
|
||||
alternates: { canonical: `/universite/${slug}/sayfa/${sayfa}` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: `${uni.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
|
||||
description: `${uni.ad} 2025 yerleştirme verileri: ${uni.programSayisi} programın taban puanları, başarı sıralamaları ve kontenjanları${uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}. 2026 tercih rehberi.`,
|
||||
alternates: { canonical: `/universite/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export function UniversiteIcerik({
|
||||
slug,
|
||||
sayfa,
|
||||
}: {
|
||||
slug: string;
|
||||
sayfa: number;
|
||||
}) {
|
||||
const uni = getUniversiteBySlug(slug);
|
||||
if (!uni) notFound();
|
||||
|
||||
// Sayfalama fakülte sıralı düz liste üzerinden; dilim içi yeniden gruplanır
|
||||
const tumProgramlar = uni.fakulteler.flatMap((f) => f.programlar);
|
||||
const toplamSayfa = uniToplamSayfa(tumProgramlar.length);
|
||||
if (sayfa < 1 || sayfa > toplamSayfa) notFound();
|
||||
const dilim = tumProgramlar.slice(
|
||||
(sayfa - 1) * SAYFA_BOYU,
|
||||
sayfa * SAYFA_BOYU,
|
||||
);
|
||||
const gruplar: {
|
||||
fakulte: string;
|
||||
programlar: (Program & { bolumSlug: string | null })[];
|
||||
}[] = [];
|
||||
for (const p of dilim) {
|
||||
const anahtar = p.fakulte ?? "Diğer";
|
||||
const satir = { ...p, bolumSlug: bolumSlugFromIsim(p.isim) };
|
||||
const son = gruplar[gruplar.length - 1];
|
||||
if (son && son.fakulte === anahtar) son.programlar.push(satir);
|
||||
else gruplar.push({ fakulte: anahtar, programlar: [satir] });
|
||||
}
|
||||
|
||||
const ilkSayfa = sayfa === 1;
|
||||
const buYol = ilkSayfa
|
||||
? `/universite/${slug}`
|
||||
: `/universite/${slug}/sayfa/${sayfa}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
{ name: "Üniversiteler", path: "/universiteler" },
|
||||
{ name: uni.ad, path: `/universite/${slug}` },
|
||||
...(ilkSayfa ? [] : [{ name: `Sayfa ${sayfa}`, path: buYol }]),
|
||||
])}
|
||||
/>
|
||||
{ilkSayfa ? (
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollegeOrUniversity",
|
||||
name: uni.ad,
|
||||
url: `${SITE_URL}/universite/${slug}`,
|
||||
...(uniLogoYolu(slug)
|
||||
? { logo: `${SITE_URL}${uniLogoYolu(slug)}` }
|
||||
: {}),
|
||||
...(uni.il
|
||||
? {
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: trBaslikDuzeni(uni.il),
|
||||
addressCountry: "TR",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
className="flex items-center gap-1 text-xs text-slate-500"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary hover:underline">
|
||||
Ana Sayfa
|
||||
</Link>
|
||||
<ChevronRight className="size-3" aria-hidden />
|
||||
<Link
|
||||
href="/universiteler"
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
Üniversiteler
|
||||
</Link>
|
||||
<ChevronRight className="size-3" aria-hidden />
|
||||
{ilkSayfa ? (
|
||||
<span className="text-slate-700">{uni.ad}</span>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={`/universite/${slug}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
{uni.ad}
|
||||
</Link>
|
||||
<ChevronRight className="size-3" aria-hidden />
|
||||
<span className="text-slate-700">Sayfa {sayfa}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<UniLogo slug={slug} ad={uni.ad} boy="lg" />
|
||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
{uni.ad} Taban Puanları 2026
|
||||
{ilkSayfa ? "" : ` — Sayfa ${sayfa}`}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{ilkSayfa ? (
|
||||
<>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{uni.ad}
|
||||
{uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}, 2025 YKS
|
||||
yerleştirme sonuçlarına göre{" "}
|
||||
<strong>{sayi(uni.programSayisi)} programda</strong> öğrenci aldı
|
||||
{uni.fakulteSayisi > 1
|
||||
? `; programlar ${sayi(uni.fakulteSayisi)} fakülte/yüksekokula dağılıyor`
|
||||
: ""}
|
||||
.
|
||||
{uni.stats.minSira != null ? (
|
||||
<>
|
||||
{" "}
|
||||
En iyi taban başarı sıralaması{" "}
|
||||
<strong>{sayi(uni.stats.minSira)}</strong>.
|
||||
</>
|
||||
) : null}
|
||||
{uni.stats.toplamKontenjan > 0 ? (
|
||||
<> Toplam kontenjan {sayi(uni.stats.toplamKontenjan)}.</>
|
||||
) : null}{" "}
|
||||
Aşağıdaki tablolar 2026 tercihleri için rehber niteliğindedir;{" "}
|
||||
<em>
|
||||
2026 taban puanları ve sıralamaları, yerleştirme sonuçları
|
||||
açıklandığında netleşir.
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
|
||||
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
|
||||
<dl className="contents">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Program
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{sayi(uni.programSayisi)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Fakülte / Birim
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{sayi(uni.fakulteSayisi)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Tür
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{uni.unitur === "DEVLET"
|
||||
? "Devlet"
|
||||
: uni.unitur
|
||||
? trBaslikDuzeni(uni.unitur)
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
En iyi sıralama
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{uni.stats.minSira != null ? sayi(uni.stats.minSira) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{uni.ad} taban puanları tablosunun {sayfa}. sayfası (
|
||||
{sayi(tumProgramlar.length)} programın{" "}
|
||||
{sayi((sayfa - 1) * SAYFA_BOYU + 1)}–
|
||||
{sayi(Math.min(sayfa * SAYFA_BOYU, tumProgramlar.length))}.
|
||||
satırları, fakülte sırasıyla). Üniversite özeti ve istatistikler{" "}
|
||||
<Link
|
||||
href={`/universite/${slug}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
ilk sayfada
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PagePixelDivider seed={57} className="mt-8" />
|
||||
|
||||
<div id="programlar" className="mt-8">
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
Üniversite programları
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Fakülte ve birimlere göre
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UniversiteProgramTablosu gruplar={gruplar} />
|
||||
|
||||
<p className="mt-3 text-xs leading-5 text-slate-500">
|
||||
"—": o yıl için yerleştirme verisi bulunmuyor (yeni açılan
|
||||
veya o yıl yerleşen çıkmayan program). Kaynak: YÖK Atlas 2025
|
||||
yerleştirme sonuçları.
|
||||
</p>
|
||||
|
||||
<Sayfalama
|
||||
tabanYol={`/universite/${slug}`}
|
||||
sayfa={sayfa}
|
||||
toplamSayfa={toplamSayfa}
|
||||
/>
|
||||
|
||||
<div className="mt-10">
|
||||
<CtaSiraForm
|
||||
baslik={`Sıralamanla ${uni.ad} programlarına yerleşir misin?`}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UniversiteIcerikSkeleton() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Skeleton className="size-14 rounded-lg" />
|
||||
<Skeleton className="h-10 w-2/3 max-w-xl" />
|
||||
</div>
|
||||
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-16 space-y-3">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import {
|
||||
HARITA_GENISLIK,
|
||||
HARITA_UST_KIRPMA,
|
||||
HARITA_YUKSEKLIK,
|
||||
normalizeIlAdi,
|
||||
} from "@/lib/harita";
|
||||
|
||||
export function UniversiteKonumHaritasi({
|
||||
universite,
|
||||
il,
|
||||
}: {
|
||||
universite: string;
|
||||
il: string | null;
|
||||
}) {
|
||||
if (!il) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="universite-konumu"
|
||||
className="relative col-span-2 min-h-44 lg:col-span-1 lg:col-start-3 lg:row-start-1 lg:row-span-2 lg:min-h-0"
|
||||
>
|
||||
<h2 id="universite-konumu" className="sr-only">
|
||||
{universite} kampüs konumu
|
||||
</h2>
|
||||
|
||||
<div className="relative h-full min-h-44 w-full">
|
||||
<svg
|
||||
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
role="img"
|
||||
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
|
||||
>
|
||||
{cities.map((city) => {
|
||||
const secili = il ? normalizeIlAdi(city.name) === il : false;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
d={city.path}
|
||||
fill={
|
||||
secili
|
||||
? "oklch(0.809 0.105 251.8)"
|
||||
: "oklch(0.929 0.013 255.5)"
|
||||
}
|
||||
stroke="white"
|
||||
strokeWidth={secili ? 1.8 : 1}
|
||||
>
|
||||
<title>{city.name}</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
110
src/features/katalog/components/universite-listesi.tsx
Normal file
110
src/features/katalog/components/universite-listesi.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import Link from "next/link";
|
||||
import { getAllUniversiteler, type UniOzet } from "../katalog-queries";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
|
||||
return (
|
||||
<ul className="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||
{uniler.map((u) => (
|
||||
<li key={u.slug}>
|
||||
<Link
|
||||
href={`/universite/${u.slug}`}
|
||||
className="group flex items-center gap-3 text-sm text-slate-700 hover:text-primary"
|
||||
>
|
||||
<UniLogo slug={u.slug} ad={u.ad} boy="sm" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate group-hover:underline">
|
||||
{u.ad}
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
{u.il ? `${trBaslikDuzeni(u.il)} · ` : ""}
|
||||
{sayi(u.programSayisi)} program
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function UniversiteKatalogu() {
|
||||
const hepsi = getAllUniversiteler();
|
||||
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
|
||||
const vakif = hepsi.filter(
|
||||
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
|
||||
);
|
||||
const digerUniler = hepsi.filter(
|
||||
(u) => !devlet.includes(u) && !vakif.includes(u),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
|
||||
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
|
||||
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler YÖK
|
||||
Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için rehber
|
||||
niteliğindedir.
|
||||
</p>
|
||||
|
||||
<PagePixelDivider seed={63} className="mt-8" />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Devlet Üniversiteleri
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(devlet.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={devlet} />
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Vakıf Üniversiteleri
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(vakif.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={vakif} />
|
||||
</section>
|
||||
|
||||
{digerUniler.length > 0 ? (
|
||||
<section className="mt-10">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
KKTC ve Yurt Dışı
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(digerUniler.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={digerUniler} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UniversiteKataloguSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
|
||||
<div className="mt-8 space-y-4">
|
||||
<Skeleton className="h-8 w-72" />
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full max-w-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/features/katalog/components/universite-program-tablosu.tsx
Normal file
135
src/features/katalog/components/universite-program-tablosu.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "./program-netleri";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
const TUR_ETIKET: Record<string, string> = {
|
||||
SAYISAL: "SAY",
|
||||
"EŞİT AĞIRLIK": "EA",
|
||||
SÖZEL: "SÖZ",
|
||||
DİL: "DİL",
|
||||
TYT: "TYT",
|
||||
};
|
||||
|
||||
export type UniProgramSatiri = Program & { bolumSlug: string | null };
|
||||
|
||||
export function UniversiteProgramTablosu({
|
||||
gruplar,
|
||||
}: {
|
||||
gruplar: { fakulte: string; programlar: UniProgramSatiri[] }[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{gruplar.map(({ fakulte, programlar }, i) => (
|
||||
<section key={`${fakulte}-${i}`} className="mt-6">
|
||||
<h2 className="mb-3 font-heading text-xl font-bold text-slate-900">
|
||||
{fakulte}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right lg:table-cell">
|
||||
2025 puan
|
||||
</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>
|
||||
{programlar.map((p) => {
|
||||
const taban = programEtkinSira(p);
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
{p.bolumSlug ? (
|
||||
<Link
|
||||
href={`/bolum/${p.bolumSlug}`}
|
||||
className="block truncate text-sm font-semibold text-slate-900 hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{p.isim.trim()}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{p.isim.trim()}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<span className="shrink-0 rounded bg-slate-100 px-1 py-px text-[10px] font-medium leading-4 text-slate-600">
|
||||
{TUR_ETIKET[p.tur] ?? p.tur}
|
||||
</span>
|
||||
{p.sure ? (
|
||||
<span className="shrink-0">{p.sure} yıl</span>
|
||||
) : null}
|
||||
</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 lg:table-cell">
|
||||
{p.puan2025 != null
|
||||
? p.puan2025.toFixed(2).replace(".", ",")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
|
||||
{p.kontenjan2025 != null
|
||||
? `${sayi(p.kontenjan2025)} kişi`
|
||||
: "—"}
|
||||
</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 ? `~${sayi(taban)}.` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-3 text-right">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
src/features/katalog/katalog-queries.ts
Normal file
17
src/features/katalog/katalog-queries.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import "server-only";
|
||||
|
||||
// Katalog feature'ının sorgu kapısı. Veri motoru @/lib/katalog'da yaşar
|
||||
// (yokatlas.db üzerinde salt-okunur, globalThis'te önbellekli); feature
|
||||
// bileşenleri ve sayfa yardımcıları yalnızca buradan okur.
|
||||
export {
|
||||
getAllBolumler,
|
||||
getAllBolumSlugs,
|
||||
getAllUniversiteler,
|
||||
getAllUniversiteSlugs,
|
||||
getBolumBySlug,
|
||||
getUniversiteBySlug,
|
||||
uniSlugFromAd,
|
||||
bolumSlugFromIsim,
|
||||
type BolumOzet,
|
||||
type UniOzet,
|
||||
} from "@/lib/katalog";
|
||||
Reference in New Issue
Block a user