Katalog 464 oturum → 12 sıra girişi; kart tablonun altında kalıyordu. CtaSiraForm'a varyant="kompakt" + konum prop'u; olay adları aynı, konum: ust|alt özelliği eklendi; alan kimlikleri useId ile (aynı sayfada iki form). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
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,
|
||
sayfaMetadata,
|
||
ustSegmentOgGorseli,
|
||
} from "@/lib/seo";
|
||
import {
|
||
KATALOG_CTA_ACIKLAMA,
|
||
SAYFA_BOYU,
|
||
sayfaDilimi,
|
||
siraYiliEtiketi,
|
||
} from "./sayfalama";
|
||
import { BolumProgramListesi } from "./bolum-program-listesi";
|
||
import { VeriKaynagi } from "./veri-kaynagi";
|
||
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
|
||
import { SiteFooter } from "@/components/site-footer";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
BASLIK_YILI,
|
||
ONCEKI_YIL,
|
||
SON_YIL,
|
||
TERCIH_YILI,
|
||
} from "@/lib/veri-yillari";
|
||
|
||
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),
|
||
}));
|
||
});
|
||
}
|
||
|
||
type BolumDetay = NonNullable<ReturnType<typeof getBolumBySlug>>;
|
||
|
||
/**
|
||
* Sayfanın satır dilimi + o dilimin taban sırası aralığı. Liste efektif sıraya
|
||
* göre dizili (lisans, sonra önlisans) olduğundan her derin sayfa belirli bir
|
||
* sıralama aralığını kapsar. Metadata ve gövde aynı hesabı buradan alır; ikisi
|
||
* ayrı ayrı dilimlerse başlık ile tablo birbirinden kayabilir.
|
||
*/
|
||
function bolumSayfaDilimi(bolum: BolumDetay, sayfa: number) {
|
||
const tumProgramlar = [
|
||
...bolum.lisansProgramlari,
|
||
...bolum.onlisansProgramlari,
|
||
];
|
||
const dilim = sayfaDilimi(tumProgramlar, sayfa);
|
||
let minSira: number | null = null;
|
||
let maxSira: number | null = null;
|
||
let verisiz = 0;
|
||
// Sırası son yılda olmayıp bir önceki yıldan gelen satırlar: aralık metni
|
||
// yılı dürüstçe söylesin (tablonun Sıralama sütunu bu satırlarda "—" gösterir).
|
||
let yalnizOnceki = 0;
|
||
for (const p of dilim) {
|
||
const efektif = p.efektifSira;
|
||
if (efektif == null) {
|
||
verisiz += 1;
|
||
continue;
|
||
}
|
||
if (p.siraSon == null) yalnizOnceki += 1;
|
||
if (minSira == null || efektif < minSira) minSira = efektif;
|
||
if (maxSira == null || efektif > maxSira) maxSira = efektif;
|
||
}
|
||
return { tumProgramlar, dilim, minSira, maxSira, verisiz, yalnizOnceki };
|
||
}
|
||
|
||
/** <title> için kısa sıra: 2.425.061 → "2,43 mn", 95.400 → "95 bin". */
|
||
function kisaSira(n: number): string {
|
||
if (n >= 1_000_000) {
|
||
return `${(n / 1e6).toLocaleString("tr-TR", { maximumFractionDigits: 2 })} mn`;
|
||
}
|
||
return n >= 10_000 ? `${Math.round(n / 1000)} bin` : sayi(n);
|
||
}
|
||
|
||
/**
|
||
* Dilimin okunur özeti ("taban sırası 95.000–180.000 arası programlar").
|
||
* Dilim boşsa null (çağıran sayfa numaralı eski metne düşer); dilimde hiç
|
||
* taban verisi yoksa aralık yerine bunu söyler.
|
||
*/
|
||
function siraAraligiMetni({
|
||
dilim,
|
||
minSira,
|
||
maxSira,
|
||
verisiz,
|
||
yalnizOnceki,
|
||
}: ReturnType<typeof bolumSayfaDilimi>): string | null {
|
||
if (dilim.length === 0) return null;
|
||
if (minSira == null || maxSira == null) {
|
||
return "taban verisi olmayan programlar";
|
||
}
|
||
const yil = siraYiliEtiketi(yalnizOnceki, dilim.length - verisiz);
|
||
const aralik =
|
||
minSira === maxSira
|
||
? `${yil} taban sırası ${sayi(minSira)} olan programlar`
|
||
: `${yil} taban sırası ${sayi(minSira)}–${sayi(maxSira)} arası programlar`;
|
||
return verisiz > 0 ? `${aralik} ile taban verisi olmayanlar` : aralik;
|
||
}
|
||
|
||
export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||
const bolum = getBolumBySlug(slug);
|
||
if (!bolum) return {};
|
||
const { stats } = bolum;
|
||
if (sayfa > 1) {
|
||
const sayfaDilim = bolumSayfaDilimi(bolum, sayfa);
|
||
const aralik = siraAraligiMetni(sayfaDilim);
|
||
const { minSira, maxSira } = sayfaDilim;
|
||
// <title> kısa kalsın (arama sonucunda ayırt edici kısım kesilmesin):
|
||
// yuvarlanmış rakamlar, kök şablonun " — KolayTercih" eki yok; açıklama tam
|
||
// cümleyi taşır. "s. N" yuvarlamada çakışan iki sayfayı yine ayırır.
|
||
const baslikEki =
|
||
minSira != null && maxSira != null
|
||
? minSira === maxSira
|
||
? `Sıra ${kisaSira(minSira)}, s. ${sayfa}`
|
||
: `Sıra ${kisaSira(minSira)}–${kisaSira(maxSira)}, s. ${sayfa}`
|
||
: `Sayfa ${sayfa}`;
|
||
const baslik = `${bolum.ad} Taban Puanları (${baslikEki})`;
|
||
const meta = sayfaMetadata({
|
||
title: baslik,
|
||
description: aralik
|
||
? `${bolum.ad} taban puanları, sayfa ${sayfa}: ${aralik}. Toplam ${stats.toplam} programın son yerleştirme verileri (${SON_YIL}; o yıl verisi olmayanlarda ${ONCEKI_YIL}) — taban puanı, başarı sıralaması ve kontenjan.`
|
||
: `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın ${SON_YIL} yerleştirme verileri.`,
|
||
path: `/bolum/${slug}/sayfa/${sayfa}`,
|
||
openGraph: {
|
||
images: ustSegmentOgGorseli(
|
||
`/bolum/${slug}`,
|
||
"Bölüm taban puanları ve sıralamaları — KolayTercih",
|
||
),
|
||
},
|
||
});
|
||
return { ...meta, title: { absolute: baslik } };
|
||
}
|
||
const enIyi =
|
||
stats.minSira != null
|
||
? ` En iyi taban sıralaması ${sayi(stats.minSira)}.`
|
||
: "";
|
||
return sayfaMetadata({
|
||
title: `${bolum.ad} Taban Puanları ve Başarı Sıralamaları ${BASLIK_YILI}`,
|
||
description: `${bolum.ad} ${SON_YIL} yerleştirme verileri: ${stats.toplam} programın taban puanı ve sıralaması, ${stats.devlet} devlet / ${stats.vakif} vakıf.${enIyi} ${TERCIH_YILI} tercih rehberi.`,
|
||
path: `/bolum/${slug}`,
|
||
});
|
||
}
|
||
|
||
export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||
const bolum = getBolumBySlug(slug);
|
||
if (!bolum) notFound();
|
||
|
||
const sayfaDilim = bolumSayfaDilimi(bolum, sayfa);
|
||
const { tumProgramlar } = sayfaDilim;
|
||
const toplamSayfa = bolumToplamSayfa(tumProgramlar.length);
|
||
if (sayfa < 1 || sayfa > toplamSayfa) notFound();
|
||
const ilkSayfa = sayfa === 1;
|
||
const aralik = siraAraligiMetni(sayfaDilim);
|
||
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
|
||
// Üst satır ve alt kart aynı soruyu sorar; sayfalama sayfalarında da bölüm
|
||
// adı geçer (ziyaretçi Google'dan doğrudan /sayfa/N'ye iniyor).
|
||
const ctaBaslik = `Sıralaman ${bolum.ad} tabanlarının neresinde?`;
|
||
|
||
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 />
|
||
{ilkSayfa ? (
|
||
<span className="text-foreground">{bolum.ad}</span>
|
||
) : (
|
||
<>
|
||
<Link
|
||
href={`/bolum/${slug}`}
|
||
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
{bolum.ad}
|
||
</Link>
|
||
<ChevronRight className="size-3.5" aria-hidden />
|
||
<span className="text-foreground">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}
|
||
{ilkSayfa ? "" : ` — Sayfa ${sayfa}`}
|
||
{!ilkSayfa && aralik ? (
|
||
<>
|
||
{" "}
|
||
<span className="mt-2 block text-lg font-semibold tracking-normal text-muted-foreground sm:text-2xl">
|
||
{aralik.charAt(0).toLocaleUpperCase("tr-TR") + aralik.slice(1)}
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</h1>
|
||
{ilkSayfa ? (
|
||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||
{sayi(tumProgramlar.length)} üniversite programının {SON_YIL} taban
|
||
puanlarını, başarı sıralamalarını ve kontenjanlarını incele.
|
||
</p>
|
||
) : (
|
||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||
{bolum.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ı, taban sırasına göre)
|
||
{aralik ? `: ${aralik}` : ""}. Listenin başı{" "}
|
||
<Link
|
||
href={`/bolum/${slug}`}
|
||
className="font-medium text-primary hover:underline"
|
||
>
|
||
ilk sayfada
|
||
</Link>
|
||
.
|
||
</p>
|
||
)}
|
||
</header>
|
||
|
||
{/* Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra girişi
|
||
başlık bloğunun hemen altında, listenin üstünde. Alttaki kart kalır. */}
|
||
<div className="mt-6">
|
||
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
|
||
</div>
|
||
|
||
<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}
|
||
/>
|
||
<VeriKaynagi className="mt-6" />
|
||
</section>
|
||
|
||
{/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */}
|
||
<div className="mt-10">
|
||
<CtaSiraForm
|
||
konum="alt"
|
||
baslik={ctaBaslik}
|
||
aciklama={KATALOG_CTA_ACIKLAMA}
|
||
/>
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|