- Danışman: program_netleri, program_karsilastir ve bolum_istihdam araçları; program_ara'ya devlet/vakıf, öğretim türü ve sıralama parametreleri; program_detay'a trend cümlesi; bozuk araç argümanı modele hata döner. - TÜİK Yükseköğretim İstihdam Göstergeleri 2025: bölüm bazında istihdam oranı, ilk işi bulma süresi ve kazanç grubu (src/lib/bolum-istihdam.ts, üretici scripts/tuik-istihdam-uret.py, kaynak Excel'ler data/kaynak/). - Bölüm sayfasında "Mezun istihdamı" ayrıntı satırı ve alt sayfası; AkademikAyrinti başlık/ikon alır. - Jev (TypeSafe AI) ile danışman cevabında garanti/olasılık dili denetimi; ihlalde cevabın sonuna düzeltme notu. Anahtar yoksa denetim atlanır. - /gizlilik: TypeSafe AI (ABD) sağlayıcı satırı. KARARLAR.md: 5 Ekim kaydı. - Üç katalog dosyasındaki bg-white → bg-card değişiklikleri de bu commit'te. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
681 lines
25 KiB
TypeScript
681 lines
25 KiB
TypeScript
import type { Metadata } from "next";
|
||
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import {
|
||
Briefcase,
|
||
Building2,
|
||
Calculator,
|
||
ChevronRight,
|
||
GraduationCap,
|
||
Landmark,
|
||
LayoutList,
|
||
Target,
|
||
} from "lucide-react";
|
||
import {
|
||
getAllBolumler,
|
||
getAllBolumSlugs,
|
||
getBolumBySlug,
|
||
uniSlugFromAd,
|
||
} from "../katalog-queries";
|
||
import type { Program } from "@/types/yokatlas";
|
||
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 { AkademikAyrinti } from "./akademik-ayrinti";
|
||
import { OzetKart } from "@/components/ozet-kart";
|
||
import {
|
||
BOLUM_ISTIHDAM_KAYNAK_URL,
|
||
BOLUM_ISTIHDAM_YIL,
|
||
ISTIHDAM_ORTALAMA,
|
||
bolumIstihdamBul,
|
||
type BolumIstihdam,
|
||
type IstihdamDuzeyi,
|
||
} from "@/lib/bolum-istihdam";
|
||
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) };
|
||
}
|
||
|
||
const ondalik = (n: number) =>
|
||
n.toLocaleString("tr-TR", { maximumFractionDigits: 1 });
|
||
|
||
/** Ayrıntı satırının ikinci satırı: içeride ne olduğunu söyler (DESIGN.md §4.9). */
|
||
function istihdamOnizleme(v: BolumIstihdam): string {
|
||
const parcalar = [
|
||
v.istihdam != null
|
||
? `Mezunların %${ondalik(v.istihdam)}'i kayıtlı çalışıyor`
|
||
: null,
|
||
v.isBulmaAy != null ? `ilk iş ${ondalik(v.isBulmaAy)} ayda` : null,
|
||
v.istihdam == null && v.kazanc
|
||
? `kazanç grubu: ${v.kazanc.toLocaleLowerCase("tr-TR")}`
|
||
: null,
|
||
].filter(Boolean);
|
||
const metin = parcalar.join(" · ");
|
||
return metin.charAt(0).toLocaleUpperCase("tr-TR") + metin.slice(1);
|
||
}
|
||
|
||
/**
|
||
* Mezun istihdamı: TÜİK Yükseköğretim İstihdam Göstergeleri (ülke geneli,
|
||
* bölüm bazında). Sayfada değil, özet kartlarının altındaki "Mezun
|
||
* istihdamı ›" satırının açtığı alt sayfada durur (DESIGN.md §4.9–4.11):
|
||
* kart ve süs ayraç yok, gösterge kareleri masonry, altında tek not ve
|
||
* kaynak. Renk sempati katar, anlam taşımaz — risk dili değildir. data-*
|
||
* işaretleri açılış koreografisinin sözleşmesi (akademik-ayrinti.tsx).
|
||
*/
|
||
function MezunIstihdami({
|
||
veri,
|
||
duzey,
|
||
}: {
|
||
veri: BolumIstihdam;
|
||
duzey: IstihdamDuzeyi;
|
||
}) {
|
||
const ort = ISTIHDAM_ORTALAMA[duzey];
|
||
const kare =
|
||
"flex flex-col items-center justify-center rounded-3xl px-3 text-white";
|
||
const etiket = "text-sm font-bold text-white/90";
|
||
const devSayi =
|
||
"font-heading text-5xl font-bold leading-none tracking-tight";
|
||
const orta = "font-heading text-3xl font-bold leading-none";
|
||
|
||
const istihdamKaresi =
|
||
veri.istihdam != null ? (
|
||
<div data-kare="sol" className={`${kare} min-h-40 bg-orange-500 py-4`}>
|
||
<dt className={etiket}>Kayıtlı çalışan</dt>
|
||
<dd className={`mt-3 ${devSayi}`}>%{ondalik(veri.istihdam)}</dd>
|
||
{ort.istihdam != null ? (
|
||
<dd className="mt-2 text-base font-bold text-white/90">
|
||
Türkiye: %{ondalik(ort.istihdam)}
|
||
</dd>
|
||
) : null}
|
||
</div>
|
||
) : null;
|
||
const kazancKaresi = veri.kazanc ? (
|
||
<div data-kare="sol" className={`${kare} min-h-24 bg-indigo-500 py-3`}>
|
||
<dd className={orta}>{veri.kazanc}</dd>
|
||
<dt className={`mt-1.5 ${etiket}`}>Kazanç grubu</dt>
|
||
</div>
|
||
) : null;
|
||
const sureKaresi =
|
||
veri.isBulmaAy != null ? (
|
||
<div data-kare="sag-ust" className={`${kare} min-h-40 bg-primary py-4`}>
|
||
<dt className={etiket}>İlk işi bulma</dt>
|
||
<dd className="mt-3 flex items-baseline justify-center gap-1">
|
||
<span className={devSayi}>{ondalik(veri.isBulmaAy)}</span>
|
||
<span className="text-base font-bold text-white/80">ay</span>
|
||
</dd>
|
||
{ort.isBulmaAy != null ? (
|
||
<dd className="mt-2 text-base font-bold text-white/90">
|
||
Türkiye: {ondalik(ort.isBulmaAy)} ay
|
||
</dd>
|
||
) : null}
|
||
</div>
|
||
) : null;
|
||
const solVar = istihdamKaresi != null || kazancKaresi != null;
|
||
|
||
return (
|
||
<div className="flex flex-col items-center text-center">
|
||
<dl
|
||
data-sahne
|
||
className="relative flex w-full max-w-md items-start gap-3"
|
||
>
|
||
{solVar ? (
|
||
<div className="flex flex-1 flex-col gap-3">
|
||
{istihdamKaresi}
|
||
{kazancKaresi}
|
||
</div>
|
||
) : null}
|
||
{sureKaresi ? (
|
||
<div
|
||
className={`flex flex-1 flex-col gap-3 ${solVar ? "mt-12" : ""}`}
|
||
>
|
||
{sureKaresi}
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
|
||
<p
|
||
data-sonra
|
||
className="mt-4 max-w-md text-sm leading-relaxed text-slate-500"
|
||
>
|
||
Türkiye geneli, tüm üniversitelerin{" "}
|
||
{duzey === "lisans" ? "lisans" : "ön lisans"} mezunları birlikte;
|
||
üniversiteye göre değişir. Kayıtlı çalışan, sigortalı bir işte
|
||
çalışanların oranıdır. Kazanç grubu bölümler arası kıyastır, maaş
|
||
değildir.
|
||
</p>
|
||
|
||
<div
|
||
data-sonra
|
||
className="mt-5 w-full max-w-md border-t border-slate-100 pt-4 text-xs leading-5 text-slate-500"
|
||
>
|
||
<p>
|
||
TÜİK, Yükseköğretim İstihdam Göstergeleri {BOLUM_ISTIHDAM_YIL} ·{" "}
|
||
<a
|
||
href={BOLUM_ISTIHDAM_KAYNAK_URL}
|
||
target="_blank"
|
||
rel="noopener"
|
||
className="font-medium text-primary transition-colors hover:text-orange-500 hover:underline"
|
||
>
|
||
veriportali.tuik.gov.tr
|
||
</a>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* İlk sayfanın açılış cümleleri: kaç ilde kaç program, devlet/vakıf dağılımı,
|
||
* toplam kontenjan, taban sıralaması aralığı ve bir önceki yıla göre yön.
|
||
* Yön için yalnızca iki yılda da sırası olan programlar sayılır; sıra sayısı
|
||
* büyümüşse o programa daha geriden yerleşilmiş demektir (öğrenci dilinde
|
||
* "gevşedi"). Beş programdan azsa yön cümlesi kurulmaz.
|
||
*/
|
||
function bolumVeriOzeti(bolum: BolumDetay, programlar: Program[]): string {
|
||
const { stats } = bolum;
|
||
const iller = new Set<string>();
|
||
let kontenjan = 0;
|
||
let gevseyen = 0;
|
||
let sikilasan = 0;
|
||
let karsilastirilan = 0;
|
||
for (const p of programlar) {
|
||
if (p.il) iller.add(p.il);
|
||
kontenjan += p.kontenjanSon ?? 0;
|
||
const onceki = p.siraGecmisi[p.siraGecmisi.length - 2];
|
||
if (p.siraSon != null && onceki != null) {
|
||
karsilastirilan += 1;
|
||
if (p.siraSon > onceki) gevseyen += 1;
|
||
else if (p.siraSon < onceki) sikilasan += 1;
|
||
}
|
||
}
|
||
const digerTur = stats.toplam - stats.devlet - stats.vakif;
|
||
const dagilim = [
|
||
stats.devlet > 0 ? `${sayi(stats.devlet)} devlet` : null,
|
||
stats.vakif > 0 ? `${sayi(stats.vakif)} vakıf` : null,
|
||
digerTur > 0 ? `${sayi(digerTur)} KKTC/yurt dışı` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
const ilSayisi = iller.size > 0 ? iller.size : stats.ilSayisi;
|
||
const cumleler = [
|
||
`${bolum.ad} bölümü ${SON_YIL} YKS yerleştirmesinde ${sayi(ilSayisi)} ilde ${sayi(stats.toplam)} programda öğrenci aldı (${dagilim}${kontenjan > 0 ? `; toplam ${sayi(kontenjan)} kontenjan` : ""}).`,
|
||
];
|
||
if (stats.minSira != null && stats.maxSira != null) {
|
||
cumleler.push(
|
||
stats.minSira === stats.maxSira
|
||
? `Taban başarı sıralaması ${sayi(stats.minSira)}.`
|
||
: `Taban başarı sıralaması programa göre ${sayi(stats.minSira)} ile ${sayi(stats.maxSira)} arasında değişiyor.`,
|
||
);
|
||
}
|
||
if (karsilastirilan >= 5) {
|
||
// Yüzdeye iyelik eki gerekmeyen kalıp: "%7'sinde / %9'unda" gibi ek
|
||
// seçimi sayının okunuşuna bağlı, şablonda güvenle kurulamaz.
|
||
const kucOran = Math.round((sikilasan / karsilastirilan) * 100);
|
||
const buyOran = Math.round((gevseyen / karsilastirilan) * 100);
|
||
cumleler.push(
|
||
kucOran >= buyOran
|
||
? `${ONCEKI_YIL}'e göre taban sıralaması sayı olarak küçülen (yerleşmesi zorlaşan) program oranı %${kucOran}, büyüyen (kolaylaşan) %${buyOran}.`
|
||
: `${ONCEKI_YIL}'e göre taban sıralaması sayı olarak büyüyen (yerleşmesi kolaylaşan) program oranı %${buyOran}, küçülen (zorlaşan) %${kucOran}.`,
|
||
);
|
||
}
|
||
return cumleler.join(" ");
|
||
}
|
||
|
||
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;
|
||
// TÜİK mezun istihdamı: lisansı olan bölümde lisans tablosu, yalnız ön
|
||
// lisansı olanda ön lisans tablosu. Tabloda yoksa satır hiç çizilmez.
|
||
const istihdamDuzeyi: IstihdamDuzeyi =
|
||
bolum.lisansProgramlari.length > 0 ? "lisans" : "onlisans";
|
||
const istihdam = bolumIstihdamBul(slug, istihdamDuzeyi);
|
||
// Sayfaya özgü, veriden türeyen özet cümle (GEO: yapay zekâ cevap motorları
|
||
// sayfa başına ilk metni saklar; 1011 katalog sayfasının aynı şablon cümleyle
|
||
// açılması hem alıntıyı hem Google'ın "ölçekli içerik" politikasını zorlar —
|
||
// docs/arastirma/2026-09-24-geo-seo). Rakamlar tablodakiyle aynı kaynaktan.
|
||
const veriOzeti = ilkSayfa ? bolumVeriOzeti(bolum, tumProgramlar) : null;
|
||
const hapSinifi =
|
||
"inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-card 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">
|
||
{veriOzeti} Aşağıdaki tabloda her programın taban puanı, başarı
|
||
sıralaması ve kontenjanı var.{" "}
|
||
<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}
|
||
{ilkSayfa && istihdam ? (
|
||
<AkademikAyrinti
|
||
baglam={bolum.ad}
|
||
baslik="Mezun istihdamı"
|
||
onizleme={istihdamOnizleme(istihdam)}
|
||
ikon={<Briefcase className="size-5 text-primary" aria-hidden />}
|
||
>
|
||
<MezunIstihdami veri={istihdam} duzey={istihdamDuzeyi} />
|
||
</AkademikAyrinti>
|
||
) : 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>
|
||
);
|
||
}
|