diff --git a/src/features/katalog/components/bolum-icerik.tsx b/src/features/katalog/components/bolum-icerik.tsx index e1fd6d9..d9b5bbf 100644 --- a/src/features/katalog/components/bolum-icerik.tsx +++ b/src/features/katalog/components/bolum-icerik.tsx @@ -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>; + +/** + * 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 +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 /> </> diff --git a/src/features/katalog/components/sayfalama.tsx b/src/features/katalog/components/sayfalama.tsx index 4802634..f86685f 100644 --- a/src/features/katalog/components/sayfalama.tsx +++ b/src/features/katalog/components/sayfalama.tsx @@ -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 diff --git a/src/features/katalog/components/universite-icerik.tsx b/src/features/katalog/components/universite-icerik.tsx index 1b47c40..207f9b7 100644 --- a/src/features/katalog/components/universite-icerik.tsx +++ b/src/features/katalog/components/universite-icerik.tsx @@ -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> diff --git a/src/features/sihirbaz/components/cta-sira-form.tsx b/src/features/sihirbaz/components/cta-sira-form.tsx index 8b3be95..202ba16 100644 --- a/src/features/sihirbaz/components/cta-sira-form.tsx +++ b/src/features/sihirbaz/components/cta-sira-form.tsx @@ -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" @@ -194,7 +204,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 @@ -213,7 +223,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