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>; /** * 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 }; } /** 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> ); }