Files
kolaytercih/src/features/katalog/components/bolum-icerik.tsx
bilalgursen 5b51fd16e0 feat(tasarim): bölüm sayfası "şişko ve sade" dile göre
- Başlık bloğu lg altında ortalı, kimlik hapları (düzey + puan türü),
  dört özet kartı (Program, Devlet, Vakıf, En iyi sıralama), piksel ayraç
- OzetKart src/components/ozet-kart.tsx'e çıkarıldı; üniversite ve bölüm
  sayfası oradan kullanıyor. 6-7 haneli sayı birimle kısalıyor (ölçüldü)
- Program listesi §4.15: ad sarar, varyant ayrı satırda, 44 px butonlar,
  telefonda taban sıra adın altında, butonlar alt alta
- Sayfalama 44 px, telefonda tek satır; hover'lar tek turuncu
- Denetim: kanonik liste satırı adının mavi hover'ı artık uyarı vermiyor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:39:21 +03:00

466 lines
17 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 {
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<string, string> = {
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<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?`;
// 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>
);
}