Merge branch 'gece/p7-katalog' into gece/entegrasyon-test-2

This commit is contained in:
bilalgursen
2026-09-21 09:27:12 +03:00
4 changed files with 227 additions and 33 deletions

View File

@@ -14,9 +14,10 @@ import {
sayfaMetadata,
ustSegmentOgGorseli,
} from "@/lib/seo";
import { SAYFA_BOYU } from "./sayfalama";
import { KATALOG_CTA_ACIKLAMA, SAYFA_BOYU, sayfaDilimi } from "./sayfalama";
import { BolumProgramListesi } from "./bolum-program-listesi";
import { VeriKaynagi } from "./veri-kaynagi";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { Skeleton } from "@/components/ui/skeleton";
@@ -42,14 +43,77 @@ export function bolumSayfaStaticParams(): { slug: string; no: string }[] {
});
}
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;
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<typeof bolumSayfaDilimi>): 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;
// <title> 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 +138,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 +176,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">
@@ -153,6 +253,14 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
/>
<VeriKaynagi className="mt-6" />
</section>
{/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */}
<div className="mt-10">
<CtaSiraForm
baslik={`Sıralaman ${bolum.ad} tabanlarının neresinde?`}
aciklama={KATALOG_CTA_ACIKLAMA}
/>
</div>
</main>
<SiteFooter />
</>

View File

@@ -5,6 +5,25 @@ 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);
}
/**
* Üniversite ve bölüm tablo sayfalarının altındaki CtaSiraForm kartının köprü
* cümlesi — iki sayfa birebir aynı metni kullansın diye tek kaynak. Sunucu
* bileşenlerinden okunduğu için "use client" olan cta-sira-form.tsx'te
* duramaz (istemci modülünün sabitleri sunucuda değer olarak okunamaz).
* Vaat içermez: tablo geçmiş veridir, ürün sıralamaya yakın programları
* risk seviyesine göre ayırıp listeler.
*/
export const KATALOG_CTA_ACIKLAMA =
"Bu tablodaki tabanlar geçmiş yerleştirmelerin sonucu; bu yıl değişebilir. Sıralamanı gir, tabanı sıralamana yakın programları risk seviyesine göre ayrılmış hâlde listeleyelim. Ücretsiz, üyelik gerekmez.";
/**
* Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in
* kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek

View File

@@ -18,7 +18,12 @@ 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 {
KATALOG_CTA_ACIKLAMA,
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 +56,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 +141,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 +317,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"
@@ -300,7 +356,8 @@ export function UniversiteIcerik({
<div className="mt-10">
<CtaSiraForm
baslik={`Sıralamanla ${uni.ad} programlarına yerleşir misin?`}
baslik={`Sıralaman ${uni.ad} tabanlarının neresinde?`}
aciklama={KATALOG_CTA_ACIKLAMA}
/>
</div>
</main>

View File

@@ -60,8 +60,19 @@ const KANITLAR = [
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
* Tasarım gerekçesi için bkz. docs/tasarim/cta-cekmece-tasarimi.md.
*
* `baslik` iki kartın (profilli/profilsiz) başlığını, `aciklama` yalnız
* profilsiz karttaki paragrafı değiştirir — katalog sayfaları kartı üstündeki
* tabloya bağlayan köprü cümlesini buradan verir. Başlık ve açıklamalar
* yerleşme vaadi içermez: ürün sıralamaya yakın programları listeler.
*/
export function CtaSiraForm({ baslik }: { baslik?: string }) {
export function CtaSiraForm({
baslik,
aciklama,
}: {
baslik?: string;
aciklama?: string;
}) {
const profil = useTercihProfili();
const router = useRouter();
@@ -146,7 +157,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
return (
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
<h2 className="font-heading text-xl font-bold text-slate-900">
{baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."}
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra) —
@@ -170,12 +181,11 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8"
>
<h2 className="font-heading text-xl font-bold text-slate-900">
{baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."}
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
YKS başarı sıralamanı gir; gerçek YÖK Atlas verisiyle sıralamana uygun
hayal, dengeli ve garanti dilimlerindeki programları anında listele.
Ücretsiz, üyelik gerekmez.
{aciklama ??
"YKS başarı sıralamanı gir; gerçek YÖK Atlas verisiyle sıralamana uygun hayal, dengeli ve garanti dilimlerindeki programları anında listele. Ücretsiz, üyelik gerekmez."}
</p>
<Button
type="button"
@@ -195,7 +205,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
>
{/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */}
<DrawerTitle className="sr-only">
Sıralamanla nereye yerleşirsin?
Sıralamana yakın programlar hangileri?
</DrawerTitle>
<DrawerDescription className="sr-only">
YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan
@@ -215,7 +225,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
<div>
<SectionEyebrow>Saniyede gör</SectionEyebrow>
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900">
Sıralamanla nereye yerleşirsin?
Sıralamana yakın programlar hangileri?
</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve