diff --git a/src/features/katalog/components/bolum-icerik.tsx b/src/features/katalog/components/bolum-icerik.tsx index e1fd6d9..c676d43 100644 --- a/src/features/katalog/components/bolum-icerik.tsx +++ b/src/features/katalog/components/bolum-icerik.tsx @@ -14,7 +14,7 @@ import { sayfaMetadata, ustSegmentOgGorseli, } from "@/lib/seo"; -import { SAYFA_BOYU } from "./sayfalama"; +import { SAYFA_BOYU, sayfaDilimi } from "./sayfalama"; import { BolumProgramListesi } from "./bolum-program-listesi"; import { VeriKaynagi } from "./veri-kaynagi"; import { SiteFooter } from "@/components/site-footer"; @@ -42,14 +42,77 @@ export function bolumSayfaStaticParams(): { slug: string; no: string }[] { }); } +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; + for (const p of dilim) { + const efektif = p.sira2025 ?? p.sira2024; + if (efektif == null) { + verisiz += 1; + continue; + } + if (minSira == null || efektif < minSira) minSira = efektif; + if (maxSira == null || efektif > maxSira) maxSira = efektif; + } + return { tumProgramlar, dilim, minSira, maxSira, verisiz }; +} + +/** + * 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, +}: ReturnType): string | null { + if (dilim.length === 0) return null; + if (minSira == null || maxSira == null) { + return "taban verisi olmayan programlar"; + } + const aralik = + minSira === maxSira + ? `taban sırası ${sayi(minSira)} olan programlar` + : `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; + // kısa kalsın: aralığın yalnız rakamları; açıklama tam cümleyi taşır. + const baslikEki = + minSira != null && maxSira != null + ? minSira === maxSira + ? `Sıra ${sayi(minSira)}, Sayfa ${sayfa}` + : `Sıra ${sayi(minSira)}–${sayi(maxSira)}, Sayfa ${sayfa}` + : `Sayfa ${sayfa}`; return sayfaMetadata({ - title: `${bolum.ad} Taban Puanları 2026 (Sayfa ${sayfa})`, - description: `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın 2025 yerleştirme verileri.`, + title: `${bolum.ad} Taban Puanları 2026 (${baslikEki})`, + description: aralik + ? `${bolum.ad} taban puanları, sayfa ${sayfa}: ${aralik}. Toplam ${stats.toplam} programın 2025 yerleştirme verileri — 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 2025 yerleştirme verileri.`, path: `/bolum/${slug}/sayfa/${sayfa}`, openGraph: { images: ustSegmentOgGorseli( @@ -74,13 +137,12 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { const bolum = getBolumBySlug(slug); if (!bolum) notFound(); - const tumProgramlar = [ - ...bolum.lisansProgramlari, - ...bolum.onlisansProgramlari, - ]; + 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}`; return ( @@ -113,20 +175,57 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { Bölümler </Link> <ChevronRight className="size-3.5" aria-hidden /> - <span className="text-foreground"> - {ilkSayfa ? bolum.ad : `Sayfa ${sayfa}`} - </span> + {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> - <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base"> - {sayi(tumProgramlar.length)} üniversite programının 2025 taban - puanlarını, başarı sıralamalarını ve kontenjanlarını incele - {!ilkSayfa ? ` — sayfa ${sayfa}` : ""}. - </p> + {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 2025 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> <section aria-labelledby="universite-programlari" className="mt-8"> diff --git a/src/features/katalog/components/sayfalama.tsx b/src/features/katalog/components/sayfalama.tsx index 4802634..5cd3c52 100644 --- a/src/features/katalog/components/sayfalama.tsx +++ b/src/features/katalog/components/sayfalama.tsx @@ -5,6 +5,14 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; /** Programatik tablo sayfaları için satır dilimi boyutu. */ export const SAYFA_BOYU = 50; +/** + * Düz listenin `sayfa` numaralı dilimi. Metadata (title/description) ile sayfa + * gövdesi aynı satırları anlatsın diye dilim hesabı tek yerde durur. + */ +export function sayfaDilimi<T>(liste: readonly T[], sayfa: number): T[] { + return liste.slice((sayfa - 1) * SAYFA_BOYU, sayfa * SAYFA_BOYU); +} + /** * Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in * kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek diff --git a/src/features/katalog/components/universite-icerik.tsx b/src/features/katalog/components/universite-icerik.tsx index 1b47c40..c473a84 100644 --- a/src/features/katalog/components/universite-icerik.tsx +++ b/src/features/katalog/components/universite-icerik.tsx @@ -18,7 +18,7 @@ import { } from "@/lib/seo"; import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form"; import { SiteFooter } from "@/components/site-footer"; -import { Sayfalama, SAYFA_BOYU } from "./sayfalama"; +import { Sayfalama, SAYFA_BOYU, sayfaDilimi } from "./sayfalama"; import { PagePixelDivider } from "@/components/pixel-decor"; import { UniversiteKonumHaritasi } from "./universite-konum-haritasi"; import { uniUlkeKonum } from "@/lib/harita-ulkeler"; @@ -51,13 +51,65 @@ export function uniSayfaStaticParams(): { slug: string; no: string }[] { }); } +type UniDetay = NonNullable<ReturnType<typeof getUniversiteBySlug>>; + +/** + * Sayfanın satır dilimi + o dilimde geçen fakülte/birim adları (tablo + * sırasıyla, tekrarsız). Liste fakülteye göre dizili olduğundan her derin + * sayfa belirli birimleri kapsar. Metadata ve gövde aynı hesabı buradan alır. + * Fakültesi boş satırlar tabloda "Diğer" grubuna düşer; ad listesine girmez. + */ +function uniSayfaDilimi(uni: UniDetay, sayfa: number) { + // Sayfalama fakülte sıralı düz liste üzerinden; dilim içi yeniden gruplanır + const tumProgramlar = uni.fakulteler.flatMap((f) => f.programlar); + const dilim = sayfaDilimi(tumProgramlar, sayfa); + const fakulteAdlari: string[] = []; + for (const p of dilim) { + if (p.fakulte && !fakulteAdlari.includes(p.fakulte)) { + fakulteAdlari.push(p.fakulte); + } + } + return { tumProgramlar, dilim, fakulteAdlari }; +} + +/** + * İlk 2 ad + "ve N birim daha"; ad yoksa null (çağıran eski metne düşer). + * Dilim tek birimden ibaretse (50'den çok programlı MYO'lar art arda iki + * sayfayı doldurabiliyor) sayfalar ayırt edilsin diye birim içi taban sırası + * aralığı eklenir — birim içinde satırlar zaten taban sırasına göre dizili. + */ +function fakulteOzeti({ + dilim, + fakulteAdlari: adlar, +}: ReturnType<typeof uniSayfaDilimi>): string | null { + if (adlar.length === 0) return null; + if (adlar.length === 1) { + const siralar = dilim + .map((p) => p.sira2025 ?? p.sira2024) + .filter((n): n is number => n != null); + if (siralar.length === 0) return adlar[0]; + const min = Math.min(...siralar); + const max = Math.max(...siralar); + return min === max + ? `${adlar[0]} (taban sırası ${sayi(min)})` + : `${adlar[0]} (taban sırası ${sayi(min)}–${sayi(max)} arası)`; + } + if (adlar.length === 2) return `${adlar[0]} ve ${adlar[1]}`; + return `${adlar[0]}, ${adlar[1]} ve ${adlar.length - 2} birim daha`; +} + export function uniSayfaMetadata(slug: string, sayfa: number): Metadata { const uni = getUniversiteBySlug(slug); if (!uni) return {}; if (sayfa > 1) { + // Üniversite adları uzun: <title> sayfa numarasıyla kalır (~60 karakter), + // farklılaştırıcı (o sayfadaki birimler) açıklamaya ve gövdeye gider. + const birimler = fakulteOzeti(uniSayfaDilimi(uni, sayfa)); return sayfaMetadata({ title: `${uni.ad} Taban Puanları 2026 (Sayfa ${sayfa})`, - description: `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın 2025 yerleştirme verileri.`, + description: birimler + ? `${uni.ad} taban puanları, sayfa ${sayfa}: ${birimler}. Toplam ${uni.programSayisi} programın 2025 yerleştirme verileri — taban puanı, başarı sıralaması ve kontenjan.` + : `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın 2025 yerleştirme verileri.`, path: `/universite/${slug}/sayfa/${sayfa}`, openGraph: { images: ustSegmentOgGorseli( @@ -84,14 +136,11 @@ export function UniversiteIcerik({ const uni = getUniversiteBySlug(slug); if (!uni) notFound(); - // Sayfalama fakülte sıralı düz liste üzerinden; dilim içi yeniden gruplanır - const tumProgramlar = uni.fakulteler.flatMap((f) => f.programlar); + const sayfaDilim = uniSayfaDilimi(uni, sayfa); + const { tumProgramlar, dilim } = sayfaDilim; const toplamSayfa = uniToplamSayfa(tumProgramlar.length); if (sayfa < 1 || sayfa > toplamSayfa) notFound(); - const dilim = tumProgramlar.slice( - (sayfa - 1) * SAYFA_BOYU, - sayfa * SAYFA_BOYU, - ); + const birimler = fakulteOzeti(sayfaDilim); const gruplar: { fakulte: string; programlar: (Program & { bolumSlug: string | null })[]; @@ -263,7 +312,9 @@ export function UniversiteIcerik({ {sayi(tumProgramlar.length)} programın{" "} {sayi((sayfa - 1) * SAYFA_BOYU + 1)}– {sayi(Math.min(sayfa * SAYFA_BOYU, tumProgramlar.length))}. - satırları, fakülte sırasıyla). Üniversite özeti ve istatistikler{" "} + satırları, fakülte sırasıyla). + {birimler ? ` Bu sayfadaki birimler: ${birimler}.` : ""} Üniversite + özeti ve istatistikler{" "} <Link href={`/universite/${slug}`} className="font-medium text-primary hover:underline"