import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { Building2, Calculator, ChevronRight, GraduationCap, Landmark, LayoutList, Target, } 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 { NavBaslik } from "./nav-baslik"; import { OzetKart } from "@/components/ozet-kart"; import { PagePixelDivider } from "@/components/pixel-decor"; 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"); /** * Özet kartında iri sayı telefonda ~94 px'e sığmalı (DESIGN.md §4.8): * 36 px Cal Sans'ta "1.618.988" 123 px ölçüldü, ikonun altına giriyor. 6 ve * 7 haneli sıralar birimle kısalır: 245.300 → "245" + "bin", * 1.618.988 → "1,62" + "milyon". */ function kartSayisi(n: number): { deger: string; birim?: string } { // 999.500+ "1.000 bin" olmasın diye milyona geçer if (n >= 999_500) { return { deger: (n / 1e6).toLocaleString("tr-TR", { maximumFractionDigits: 2 }), birim: "milyon", }; } if (n >= 100_000) return { deger: sayi(Math.round(n / 1000)), birim: "bin" }; return { deger: sayi(n) }; } /** Lisans puan türünün kimlik hapındaki adı (DB: SAYISAL, EŞİT AĞIRLIK…). */ const PUAN_TURU_HAP: Record = { SAYISAL: "Sayısal puan", "EŞİT AĞIRLIK": "Eşit ağırlık puanı", SÖZEL: "Sözel puan", DİL: "Dil puanı", }; 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?`; // Kimlik hapları (DESIGN.md §4.12): düzey + lisans puan türleri. TYT // puanı önlisansın kendisi olduğundan ayrı hap açmaz. const puanTurleri = [ ...new Set(bolum.lisansProgramlari.map((p) => p.tur)), ].flatMap((t) => (PUAN_TURU_HAP[t] ? [PUAN_TURU_HAP[t]] : [])); const duzeyler = [ ...(bolum.lisansProgramlari.length > 0 ? ["Lisans"] : []), ...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []), ]; // Özet kartları: devlet ve vakıf ayrı sayılır; KKTC ve yurt dışı // programları toplamda var ama iki karta da girmez (vakıf sayılmaz). const devletUni = new Set<string>(); const vakifUni = new Set<string>(); let vakifProgram = 0; for (const p of tumProgramlar) { if (p.unitur === "DEVLET") devletUni.add(p.universite); else if (p.unitur === "VAKIF" || p.unitur === "VAKIF MYO") { vakifProgram += 1; vakifUni.add(p.universite); } } const { stats } = bolum; const hapSinifi = "inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800"; 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: "Bölümler", path: "/bolumler" }, { name: bolum.ad, path: `/bolum/${slug}` }, ...(ilkSayfa ? [] : [{ name: `Sayfa ${sayfa}`, path: buYol }]), ])} /> <nav aria-label="İçerik yolu" className="flex flex-wrap items-center justify-center gap-1.5 text-sm text-slate-500 lg:justify-start" > <Link href="/" className="transition-colors hover:text-orange-500 hover:underline" > Ana Sayfa </Link> <ChevronRight className="size-3.5 shrink-0" aria-hidden /> <Link href="/bolumler" className="transition-colors hover:text-orange-500 hover:underline" > Bölümler </Link> <ChevronRight className="size-3.5 shrink-0" aria-hidden /> {ilkSayfa ? ( <span className="text-slate-700">{bolum.ad}</span> ) : ( <> <Link href={`/bolum/${slug}`} className="transition-colors hover:text-orange-500 hover:underline" > {bolum.ad} </Link> <ChevronRight className="size-3.5 shrink-0" aria-hidden /> <span className="text-slate-700">Sayfa {sayfa}</span> </> )} </nav> {/* Başlık üst çubuğun altına girince üst çubukta bölüm adı belirir (logosu yok — DESIGN.md §4.14). Geniş ekran (lg) altında başlık bloğu ortalı (§5.2). */} <NavBaslik baslikId="bolum-baslik" ad={bolum.ad} /> <h1 id="bolum-baslik" className="mt-6 text-center font-heading text-3xl font-bold leading-tight text-slate-900 sm:text-5xl lg:text-left" > {bolum.ad} {ilkSayfa ? "" : ` — Sayfa ${sayfa}`} {!ilkSayfa && aralik ? ( <> {" "} <span className="mt-2 block text-lg font-semibold text-slate-500 sm:text-2xl"> {aralik.charAt(0).toLocaleUpperCase("tr-TR") + aralik.slice(1)} </span> </> ) : null} </h1> {duzeyler.length > 0 || puanTurleri.length > 0 ? ( <p className="mt-5 flex flex-wrap items-center justify-center gap-2 lg:justify-start"> {duzeyler.map((d) => ( <span key={d} className={hapSinifi}> <GraduationCap className="size-4 text-primary" aria-hidden /> {d} </span> ))} {puanTurleri.map((t) => ( <span key={t} className={hapSinifi}> <Calculator className="size-4 text-primary" aria-hidden /> {t} </span> ))} </p> ) : null} {ilkSayfa ? ( <p className="mx-auto mt-6 max-w-3xl text-center text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0 lg:text-left"> {bolum.ad} bölümünü açan {sayi(tumProgramlar.length)} üniversite programının {SON_YIL} taban puanlarını, başarı sıralamalarını ve kontenjanlarını incele.{" "} <em> {TERCIH_YILI} tabanları, yerleştirme sonuçları açıklandığında netleşir. </em> </p> ) : ( <p className="mx-auto mt-6 max-w-3xl text-center text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0 lg:text-left"> {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 transition-colors hover:text-orange-500 hover:underline" > ilk sayfada </Link> . </p> )} {/* Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra girişi özet paragrafın hemen altında, listenin üstünde. Alttaki kart kalır. */} <div className="mt-8"> <CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} /> </div> {/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */} {ilkSayfa ? ( <dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4"> <OzetKart ikon={LayoutList} etiket="Program" deger={sayi(stats.toplam)} alt={`${sayi(stats.ilSayisi)} ilde`} /> <OzetKart ikon={Landmark} etiket="Devlet" deger={sayi(stats.devlet)} alt={ devletUni.size > 0 ? `${sayi(devletUni.size)} üniversitede` : "bu bölümde yok" } /> <OzetKart ikon={Building2} etiket="Vakıf" deger={sayi(vakifProgram)} alt={ vakifUni.size > 0 ? `${sayi(vakifUni.size)} üniversitede` : "bu bölümde yok" } /> <OzetKart ikon={Target} etiket="En iyi sıralama" {...(stats.minSira != null ? kartSayisi(stats.minSira) : { deger: "—" })} alt={`${SON_YIL} taban sırası`} /> </dl> ) : null} <PagePixelDivider seed={83} className="mx-auto mt-12 lg:mx-0" /> <section aria-labelledby="universite-programlari" className="mt-10"> <div className="text-center lg:text-left"> <h2 id="universite-programlari" className="font-heading text-2xl font-bold text-slate-900 sm:text-3xl" > Üniversite programları </h2> <p className="mt-2 text-base text-slate-500"> {sayi(tumProgramlar.length)} program, taban sırasına göre </p> </div> <div className="mt-6"> <BolumProgramListesi programlar={tumProgramlar.map((program) => ({ ...program, universiteSlug: uniSlugFromAd(program.universite), }))} bazAd={bolum.ad} tabanYol={`/bolum/${slug}`} sayfa={sayfa} /> </div> <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-12 sm:py-16"> <Skeleton className="mx-auto h-5 w-56 lg:mx-0" /> <Skeleton className="mx-auto mt-6 h-10 w-2/3 max-w-xl lg:mx-0" /> <Skeleton className="mx-auto mt-5 h-10 w-48 rounded-full lg:mx-0" /> <Skeleton className="mx-auto mt-6 h-5 w-full max-w-3xl lg:mx-0" /> <div className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4"> {Array.from({ length: 4 }, (_, i) => ( <Skeleton key={i} className="h-28 w-full rounded-2xl" /> ))} </div> <div className="mt-16 space-y-3"> <Skeleton className="h-7 w-64" /> {Array.from({ length: 5 }, (_, i) => ( <Skeleton key={i} className="h-20 w-full rounded-2xl" /> ))} </div> </main> ); }