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>
This commit is contained in:
bilalgursen
2026-09-24 00:39:21 +03:00
parent 6a9af19ef2
commit 5b51fd16e0
11 changed files with 394 additions and 214 deletions

View File

@@ -1,7 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import {
Building2,
Calculator,
ChevronRight,
GraduationCap,
Landmark,
LayoutList,
Target,
} from "lucide-react";
import {
getAllBolumler,
getAllBolumSlugs,
@@ -23,6 +31,8 @@ import {
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";
@@ -35,6 +45,32 @@ import {
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));
}
@@ -179,76 +215,126 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
// 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-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 }]),
])}
/>
<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="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"
<nav
aria-label="İçerik yolu"
className="flex flex-wrap items-center justify-center gap-1.5 text-sm text-slate-500 lg:justify-start"
>
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>
<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 */}
<NavBaslik baslikId="bolum-baslik" ad={bolum.ad} />
<header className="border-b pb-7 sm:pb-9">
{/* 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="max-w-3xl font-heading text-3xl font-bold tracking-tight text-foreground sm:text-5xl">
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 tracking-normal text-muted-foreground sm:text-2xl">
<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="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 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="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
<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)}–
@@ -257,54 +343,99 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
{aralik ? `: ${aralik}` : ""}. Listenin başı{" "}
<Link
href={`/bolum/${slug}`}
className="font-medium text-primary hover:underline"
className="font-medium text-primary transition-colors hover:text-orange-500 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>
{/* 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>
<BolumProgramListesi
programlar={tumProgramlar.map((program) => ({
...program,
universiteSlug: uniSlugFromAd(program.universite),
}))}
bazAd={bolum.ad}
tabanYol={`/bolum/${slug}`}
sayfa={sayfa}
/>
<VeriKaynagi className="mt-6" />
</section>
{/* 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}
{/* Ü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>
<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 />
</>
@@ -313,16 +444,20 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
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" />
<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-8 space-y-3">
<div className="mt-16 space-y-3">
<Skeleton className="h-7 w-64" />
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
<Skeleton key={i} className="h-20 w-full rounded-2xl" />
))}
</div>
</main>