Files
kolaytercih/src/features/katalog/components/bolum-icerik.tsx
bilalgursen ceeb70375d feat(veri): bileşenler yılsız Program'a ve veri-yillari sözlüğüne geçti (P-B b)
- SiraSerisi = { siraGecmisi, ilkYil? }; yardımcılar dizinin kendi son iki
  elemanıyla çalışır, yıl ilkYil + indeks. raporSiraSerisi diziyi doldurmaz:
  5 elemanlı eski raporlar 2024→2025 trendini göstermeye devam eder.
- Tablo başlıkları "Son ${VERI_YIL_SAYISI} yıl"; rapor listesi başlığı kaydın
  kendi pencere uzunluğunu söyler (eski rapor: 5).
- siraYiliEtiketi SON_YIL/ONCEKI_YIL'dan; bölüm/üniversite sayfa metinleri ve
  metadata SON_YIL, TERCIH_YILI, BASLIK_YILI sözlüğünden.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 17:00:39 +03:00

316 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`;
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>
<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
baslik={`Sıralaman ${bolum.ad} tabanlarının neresinde?`}
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>
);
}