diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index ce731ff..e2e3164 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,10 @@ import type { MetadataRoute } from "next"; import { KATALOG_VERI_TARIHI, SITE_URL } from "@/lib/seo"; -import { getAllBolumSlugs, getAllUniversiteSlugs } from "@/lib/katalog"; +import { + getAllBolumSlugs, + getAllIlSlugs, + getAllUniversiteSlugs, +} from "@/lib/katalog"; import { getAllRehberler } from "@/lib/rehber"; // Bilinçli olarak sitemap DIŞI: /sonuc (noindex), /giris, /listem, @@ -46,6 +50,12 @@ export default async function sitemap(): Promise { changeFrequency: "monthly", }), ); + const iller: MetadataRoute.Sitemap = getAllIlSlugs().map((slug) => ({ + url: `${SITE_URL}/universiteler/${slug}`, + lastModified: KATALOG_VERI_TARIHI, + priority: 0.6, + changeFrequency: "monthly", + })); const yazilar = getAllRehberler(); const rehberler: MetadataRoute.Sitemap = yazilar.map((y) => ({ url: `${SITE_URL}/rehber/${y.slug}`, @@ -72,6 +82,7 @@ export default async function sitemap(): Promise { rehberDizini, ...bolumler, ...universiteler, + ...iller, ...rehberler, ]; } diff --git a/src/app/universiteler/[il]/page.tsx b/src/app/universiteler/[il]/page.tsx new file mode 100644 index 0000000..02e74e3 --- /dev/null +++ b/src/app/universiteler/[il]/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { SiteFooter } from "@/components/site-footer"; +import { + IlIcerik, + IlIcerikSkeleton, + ilSayfaMetadata, + ilStaticSlugs, +} from "@/features/katalog/components/il-icerik"; + +export function generateStaticParams() { + return ilStaticSlugs().map((il) => ({ il })); +} + +export async function generateMetadata({ + params, +}: PageProps<"/universiteler/[il]">): Promise { + const { il } = await params; + return ilSayfaMetadata(il); +} + +export default function IlPage({ params }: PageProps<"/universiteler/[il]">) { + return ( + <> + }> + {params.then(({ il }) => ( + + ))} + + + + ); +} diff --git a/src/features/katalog/components/il-icerik.tsx b/src/features/katalog/components/il-icerik.tsx new file mode 100644 index 0000000..f0e69b2 --- /dev/null +++ b/src/features/katalog/components/il-icerik.tsx @@ -0,0 +1,213 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Building2, ChevronRight, Landmark, MapPin } from "lucide-react"; +import { + getAllIller, + getAllIlSlugs, + getIlBySlug, + type UniOzet, +} from "../katalog-queries"; +import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form"; +import { PagePixelDivider } from "@/components/pixel-decor"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + JsonLd, + breadcrumbJsonLd, + sayfaMetadata, + ustSegmentOgGorseli, +} from "@/lib/seo"; +import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari"; +import { NavBaslik } from "./nav-baslik"; +import { SehirCipleri, oneCikanIller } from "./sehir-cipleri"; +import { UniLinkleri } from "./universite-listesi"; + +const sayi = (n: number) => n.toLocaleString("tr-TR"); + +export function ilStaticSlugs(): string[] { + return getAllIlSlugs(); +} + +export function ilSayfaMetadata(slug: string): Metadata { + const il = getIlBySlug(slug); + if (!il) return {}; + return sayfaMetadata({ + title: `${il.ad} Üniversiteleri Taban Puanları ve Sıralamaları ${BASLIK_YILI}`, + description: `${il.ad}'daki ${il.uniler.length} üniversitenin ${SON_YIL} YKS yerleştirme verileri: ${sayi(il.programSayisi)} programın taban puanları, başarı sıralamaları ve kontenjanları. ${TERCIH_YILI} tercih rehberi.`, + path: `/universiteler/${slug}`, + openGraph: { + images: ustSegmentOgGorseli( + "/universiteler", + "Üniversitelerin taban puanları ve sıralamaları — KolayTercih", + ), + }, + }); +} + +function UniGrubu({ + baslik, + uniler, +}: { + baslik: string; + uniler: UniOzet[]; +}) { + if (uniler.length === 0) return null; + return ( +
+

+ {baslik} + + {sayi(uniler.length)} + +

+ +
+ ); +} + +/** + * /universiteler/[il]: bir şehirdeki üniversiteler, devlet / vakıf / diğer + * öbekleriyle. Düzen katalog detay sayfalarıyla aynı (DESIGN.md §5.2): + * yol satırı, başlık, kimlik hapları ve özet lg altında ortalı; listeler + * her zaman sola yaslı. + */ +export function IlIcerik({ slug }: { slug: string }) { + const il = getIlBySlug(slug); + if (!il) notFound(); + + const devlet: UniOzet[] = []; + const vakif: UniOzet[] = []; + const diger: UniOzet[] = []; + for (const u of il.uniler) { + if (u.unitur === "DEVLET") devlet.push(u); + else if (u.unitur === "VAKIF" || u.unitur === "VAKIF MYO") vakif.push(u); + else diger.push(u); + } + const ozetParcalar = [ + devlet.length > 0 ? `${sayi(devlet.length)} devlet` : null, + vakif.length > 0 ? `${sayi(vakif.length)} vakıf` : null, + diger.length > 0 ? `${sayi(diger.length)} KKTC/yurt dışı` : null, + ].filter(Boolean); + const ctaBaslik = `Sıralaman ${il.ad} üniversitelerinin neresinde?`; + const digerSehirler = oneCikanIller(getAllIller()); + + return ( +
+ + + + + +

+ {il.ad} Üniversiteleri Taban Puanları {BASLIK_YILI} +

+ + {/* Kimlik hapları (DESIGN.md §4.12): il + üniversite sayısı */} +

+ + + {il.ad} + + + {devlet.length >= vakif.length ? ( + + ) : ( + + )} + {sayi(il.uniler.length)} üniversite + +

+ +

+ {il.ad}'da {SON_YIL} YKS yerleştirmesinde öğrenci alan{" "} + {sayi(il.uniler.length)} üniversite + {ozetParcalar.length > 1 ? ` (${ozetParcalar.join(", ")})` : ""} ve + toplam {sayi(il.programSayisi)} program var. + Üniversiteye tıklayınca programlarının taban puanlarını, başarı + sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler + YÖK Atlas {SON_YIL} yerleştirme sonuçlarına dayanır; {TERCIH_YILI}{" "} + tercihleri için rehber niteliğindedir. +

+ +
+ +
+ + + + + + + +
+

+ Diğer şehirler +

+ +
+ +
+ +
+
+ ); +} + +export function IlIcerikSkeleton() { + return ( +
+ + +
+ + +
+ +
+ + {Array.from({ length: 6 }, (_, i) => ( + + ))} +
+
+ ); +} diff --git a/src/features/katalog/components/sehir-cipleri.tsx b/src/features/katalog/components/sehir-cipleri.tsx new file mode 100644 index 0000000..fd7452b --- /dev/null +++ b/src/features/katalog/components/sehir-cipleri.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import type { IlOzet } from "../katalog-queries"; + +const sayi = (n: number) => n.toLocaleString("tr-TR"); + +/** + * Şehir çipi (DESIGN.md §4.17): sihirbazın il çipiyle aynı kalıp — 40 px, + * tam yuvarlak, hover §2.1 tek turuncu; seçili/aktif hâl mavi dolu. + * Etkileşimli çip olduğu için rozet (§4.2) kuralının dışındadır. + */ +const CIP_TEMEL = + "inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full border px-3.5 text-sm font-semibold transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97]"; +const CIP_PASIF = + "border-slate-200 bg-white text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"; +const CIP_AKTIF = "border-primary bg-primary text-white"; + +/** + * Şehre göre üniversite gezinme çipleri. `iller` verilen sırayla dizilir + * (çağıran genelde üniversite sayısına göre azalan verir). `aktifSlug` + * bulunulan şehir sayfasıdır: mavi dolu ve `aria-current="page"`. + * `tumuHref` verilirse sona "Tüm şehirler" çipi eklenir (A–Z listesine). + */ +export function SehirCipleri({ + iller, + aktifSlug, + tumuHref, + tumuEtiket, + className, +}: { + iller: IlOzet[]; + aktifSlug?: string; + tumuHref?: string; + tumuEtiket?: string; + className?: string; +}) { + return ( +
    + {iller.map((il) => { + const aktif = il.slug === aktifSlug; + return ( +
  • + + {il.ad} + + {sayi(il.uniler.length)} + + +
  • + ); + })} + {tumuHref ? ( +
  • + + {tumuEtiket ?? "Tüm şehirler"} + +
  • + ) : null} +
+ ); +} + +/** Çip satırında gösterilecek şehirler: en az iki üniversitesi olanlar. */ +export function oneCikanIller(iller: IlOzet[]): IlOzet[] { + return iller.filter((il) => il.uniler.length >= 2); +} diff --git a/src/features/katalog/components/universite-listesi.tsx b/src/features/katalog/components/universite-listesi.tsx index 70dd8a4..ff3412e 100644 --- a/src/features/katalog/components/universite-listesi.tsx +++ b/src/features/katalog/components/universite-listesi.tsx @@ -1,6 +1,11 @@ import Link from "next/link"; -import { getAllUniversiteler, type UniOzet } from "../katalog-queries"; +import { + getAllIller, + getAllUniversiteler, + type UniOzet, +} from "../katalog-queries"; import { trBaslikDuzeni } from "@/lib/slug"; +import { SehirCipleri, oneCikanIller } from "./sehir-cipleri"; import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form"; import { PagePixelDivider } from "@/components/pixel-decor"; import { UniLogo } from "@/components/uni-logo"; @@ -9,7 +14,7 @@ import { SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari"; const sayi = (n: number) => n.toLocaleString("tr-TR"); -function UniLinkleri({ uniler }: { uniler: UniOzet[] }) { +export function UniLinkleri({ uniler }: { uniler: UniOzet[] }) { return (
    {uniler.map((u) => ( @@ -47,6 +52,10 @@ export function UniversiteKatalogu() { else digerUniler.push(u); } + const iller = getAllIller(); + const oneCikan = oneCikanIller(iller); + const illerAz = [...iller].sort((a, b) => a.ad.localeCompare(b.ad, "tr-TR")); + return ( <>

    @@ -57,6 +66,27 @@ export function UniversiteKatalogu() { için rehber niteliğindedir.

    + {/* Şehre göre gezinme: sık bakılan (2+ üniversiteli) şehirler çip + olarak önde, tam A–Z liste sayfanın altında (DESIGN.md §1.1 "sık + bakılan önde, ayrıntı bir dokunuş derinde"). */} +
    +

    + Şehre göre + + {sayi(iller.length)} şehir + +

    + +
    +
    @@ -91,6 +121,30 @@ export function UniversiteKatalogu() {
    ) : null} +
    +

    + Tüm şehirler A–Z + + {sayi(iller.length)} + +

    +
      + {illerAz.map((il) => ( +
    • + + {il.ad} + + {sayi(il.uniler.length)} üniversite + + +
    • + ))} +
    +
    +
    diff --git a/src/features/katalog/katalog-queries.ts b/src/features/katalog/katalog-queries.ts index fac7d6d..3f6526d 100644 --- a/src/features/katalog/katalog-queries.ts +++ b/src/features/katalog/katalog-queries.ts @@ -8,10 +8,14 @@ export { getAllBolumSlugs, getAllUniversiteler, getAllUniversiteSlugs, + getAllIller, + getAllIlSlugs, getBolumBySlug, + getIlBySlug, getUniversiteBySlug, uniSlugFromAd, bolumSlugFromIsim, type BolumOzet, + type IlOzet, type UniOzet, } from "@/lib/katalog"; diff --git a/src/lib/katalog.ts b/src/lib/katalog.ts index 7669d89..00871d8 100644 --- a/src/lib/katalog.ts +++ b/src/lib/katalog.ts @@ -62,6 +62,8 @@ const globalKatalog = globalThis as unknown as { bolumOzetler?: BolumOzet[]; uniMap?: Map; uniOzetler?: UniOzet[]; + iller?: IlOzet[]; + ilMap?: Map; }; function bolumMapGetir(): Map { @@ -369,6 +371,68 @@ export function getAllUniversiteSlugs(): string[] { return [...uniMapGetir().keys()]; } +// ---------- İl (şehir) tarafı: /universiteler/[il] ---------- + +export type IlOzet = { + /** turkishSlugify(il): "istanbul", "saraybosna-bosna-hersek" */ + slug: string; + /** Görünen ad: İstanbul, Bakü-Azerbaycan */ + ad: string; + /** Bu ilde yer alan üniversiteler (UniOzet.il esas; ad sırasıyla) */ + uniler: UniOzet[]; + programSayisi: number; +}; + +/** + * "BAKÜ-AZERBAYCAN" → "Bakü-Azerbaycan": trBaslikDuzeni yalnız boşlukta + * böler; yurt dışı şehirlerin tireli ülke eki de baş harfli yazılır. + */ +function ilGorunenAd(il: string): string { + return trBaslikDuzeni(il).replace(/-(\p{L})/gu, (_, harf: string) => + `-${harf.toLocaleUpperCase("tr-TR")}`, + ); +} + +/** + * Üniversiteleri iline göre kümeler; sıralama üniversite sayısına göre + * azalan, eşitlikte ad. Her üniversite tek ilde sayılır (UniOzet.il: en çok + * programının bulunduğu il). İli olmayan (NULL/boş) üniversite hiçbir ile + * girmez. KKTC ve yurt dışı şehirler de birer ildir (LEFKOŞA, BAKÜ-…). + */ +export function getAllIller(): IlOzet[] { + if (!globalKatalog.iller) { + const bySlug = new Map(); + for (const u of getAllUniversiteler()) { + const il = u.il?.trim(); + if (!il) continue; + const slug = turkishSlugify(il); + if (!slug) continue; + let kayit = bySlug.get(slug); + if (!kayit) { + kayit = { slug, ad: ilGorunenAd(il), uniler: [], programSayisi: 0 }; + bySlug.set(slug, kayit); + } + kayit.uniler.push(u); + kayit.programSayisi += u.programSayisi; + } + globalKatalog.ilMap = bySlug; + globalKatalog.iller = [...bySlug.values()].sort( + (a, b) => + b.uniler.length - a.uniler.length || a.ad.localeCompare(b.ad, "tr-TR"), + ); + } + return globalKatalog.iller; +} + +export function getAllIlSlugs(): string[] { + return getAllIller().map((il) => il.slug); +} + +export function getIlBySlug(slug: string): IlOzet | null { + getAllIller(); + return globalKatalog.ilMap!.get(slug) ?? null; +} + export function getUniversiteBySlug(slug: string): UniDetay | null { const kayit = uniMapGetir().get(slug); if (!kayit) return null;