Update routing in next.config.ts to redirect "/tercih-robotu" to the hero form, add simple-icons dependency in package.json, and enhance database indexing for improved query performance. Remove unused SVG files and update metadata across various pages for better SEO and clarity.
All checks were successful
Deploy / deploy (push) Successful in 14m14s
All checks were successful
Deploy / deploy (push) Successful in 14m14s
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { PUAN_TURLERI, rankWindowFacets, type PuanTuruKey } from "@/lib/db";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
dilimAra,
|
||||
rankWindowFacets,
|
||||
type DilimKey,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
import { KATEGORILER } from "@/lib/kategoriler";
|
||||
|
||||
// Ana sayfadaki sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite
|
||||
// tipi facet'leri. /sonuc bu veriyi server component'te doğrudan alır; burada
|
||||
// client'tan sıra girildikten sonra çekilir.
|
||||
// Sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite tipi
|
||||
// facet'leri. Kademeli filtre: `kategori` (tekrarlı) il adımını,
|
||||
// `kategori` + `il` (tekrarlı) üniversite tipi adımını süzer — bir önceki
|
||||
// adımın seçimi bir sonrakinin seçeneklerini daraltır (bkz. rankWindowFacets).
|
||||
export function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const sira = Number.parseInt(searchParams.get("sira") ?? "", 10);
|
||||
@@ -18,5 +26,31 @@ export function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ facetler: rankWindowFacets(sira, tur) });
|
||||
const kategoriler = [
|
||||
...new Set(
|
||||
searchParams
|
||||
.getAll("kategori")
|
||||
.filter((k) => (KATEGORILER as readonly string[]).includes(k)),
|
||||
),
|
||||
];
|
||||
const iller = [
|
||||
...new Set(searchParams.getAll("il").filter((i) => i.trim().length > 0)),
|
||||
].slice(0, 5);
|
||||
|
||||
// ?dilimler=1: /meraklisina'daki canlı dilim şeridi için dilim başına
|
||||
// toplam sayılar da eklenir (limit 0 → yalnızca COUNT çalışır)
|
||||
const dilimToplam =
|
||||
searchParams.get("dilimler") === "1"
|
||||
? Object.fromEntries(
|
||||
(["hayal", "dengeli", "garanti"] as DilimKey[]).map((d) => [
|
||||
d,
|
||||
dilimAra(sira, tur, d, { limit: 0 }).toplam,
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return NextResponse.json({
|
||||
facetler: rankWindowFacets(sira, tur, { kategoriler, iller }),
|
||||
...(dilimToplam ? { dilimToplam } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
86
src/app/api/katalog-ara/route.ts
Normal file
86
src/app/api/katalog-ara/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { getAllBolumler, getAllUniversiteler } from "@/lib/katalog";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
|
||||
const SONUC_SINIRI = 6;
|
||||
|
||||
function normalize(metin: string): string {
|
||||
return metin
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.replace(/ı/g, "i")
|
||||
.replace(/ş/g, "s")
|
||||
.replace(/ğ/g, "g")
|
||||
.replace(/ü/g, "u")
|
||||
.replace(/ö/g, "o")
|
||||
.replace(/ç/g, "c")
|
||||
.replace(/â/g, "a")
|
||||
.replace(/î/g, "i")
|
||||
.replace(/û/g, "u")
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "");
|
||||
}
|
||||
|
||||
function eslesmePuani(ad: string, sorgu: string): number {
|
||||
const normalAd = normalize(ad);
|
||||
if (normalAd === sorgu) return 0;
|
||||
if (normalAd.startsWith(sorgu)) return 1;
|
||||
if (normalAd.split(/\s+/).some((kelime) => kelime.startsWith(sorgu))) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
const hamSorgu = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||
const sorgu = normalize(hamSorgu).slice(0, 80);
|
||||
|
||||
if (sorgu.length < 2) {
|
||||
return NextResponse.json({ bolumler: [], universiteler: [] });
|
||||
}
|
||||
|
||||
const bolumler = getAllBolumler()
|
||||
.filter((bolum) => normalize(bolum.ad).includes(sorgu))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
eslesmePuani(a.ad, sorgu) - eslesmePuani(b.ad, sorgu) ||
|
||||
b.programSayisi - a.programSayisi,
|
||||
)
|
||||
.slice(0, SONUC_SINIRI)
|
||||
.map((bolum) => ({
|
||||
href: `/bolum/${bolum.slug}`,
|
||||
ad: bolum.ad,
|
||||
programSayisi: bolum.programSayisi,
|
||||
enIyiSira: bolum.enIyiSira,
|
||||
seviye:
|
||||
bolum.lisans && bolum.onlisans
|
||||
? "Lisans ve önlisans"
|
||||
: bolum.lisans
|
||||
? "Lisans"
|
||||
: "Önlisans",
|
||||
}));
|
||||
|
||||
const universiteler = getAllUniversiteler()
|
||||
.filter((universite) => normalize(universite.ad).includes(sorgu))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
eslesmePuani(a.ad, sorgu) - eslesmePuani(b.ad, sorgu) ||
|
||||
b.programSayisi - a.programSayisi,
|
||||
)
|
||||
.slice(0, SONUC_SINIRI)
|
||||
.map((universite) => ({
|
||||
href: `/universite/${universite.slug}`,
|
||||
ad: universite.ad,
|
||||
il: universite.il ? trBaslikDuzeni(universite.il) : null,
|
||||
tur:
|
||||
universite.unitur === "DEVLET"
|
||||
? "Devlet"
|
||||
: universite.unitur &&
|
||||
["VAKIF", "VAKIF MYO"].includes(universite.unitur)
|
||||
? "Vakıf"
|
||||
: universite.unitur
|
||||
? "KKTC / Yurt dışı"
|
||||
: null,
|
||||
programSayisi: universite.programSayisi,
|
||||
fakulteSayisi: universite.fakulteSayisi,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ bolumler, universiteler });
|
||||
}
|
||||
43
src/app/api/programlar/route.ts
Normal file
43
src/app/api/programlar/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
dilimAra,
|
||||
type DilimKey,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
|
||||
const SAYFA_BOYU = 20;
|
||||
|
||||
const DILIMLER: DilimKey[] = ["hayal", "dengeli", "garanti"];
|
||||
|
||||
// Sonuç sayfasındaki ham tablonun "daha fazla göster" sayfalaması: dilim
|
||||
// başına SAYFA_BOYU program, yakından uzağa sıralı. İlk sayfa server
|
||||
// component'te render edilir (bkz. sonuc/page.tsx); devamı buradan akar.
|
||||
export function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const sira = Number.parseInt(searchParams.get("sira") ?? "", 10);
|
||||
const turParam = searchParams.get("tur") ?? "say";
|
||||
const tur: PuanTuruKey =
|
||||
turParam in PUAN_TURLERI ? (turParam as PuanTuruKey) : "say";
|
||||
const dilimParam = searchParams.get("dilim") ?? "";
|
||||
const offset = Number.parseInt(searchParams.get("offset") ?? "0", 10);
|
||||
|
||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||
return NextResponse.json(
|
||||
{ error: "Geçerli bir sıralama gerekli." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!DILIMLER.includes(dilimParam as DilimKey)) {
|
||||
return NextResponse.json({ error: "Geçersiz dilim." }, { status: 400 });
|
||||
}
|
||||
if (!Number.isFinite(offset) || offset < 0 || offset > 100_000) {
|
||||
return NextResponse.json({ error: "Geçersiz offset." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { programlar, toplam } = dilimAra(sira, tur, dilimParam as DilimKey, {
|
||||
limit: SAYFA_BOYU,
|
||||
offset,
|
||||
});
|
||||
return NextResponse.json({ programlar, toplam });
|
||||
}
|
||||
129
src/app/bolum/[slug]/bolum-icerik.tsx
Normal file
129
src/app/bolum/[slug]/bolum-icerik.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
export function bolumToplamSayfa(programSayisi: number): number {
|
||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||
}
|
||||
|
||||
export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||
const bolum = getBolumBySlug(slug);
|
||||
if (!bolum) return {};
|
||||
const { stats } = bolum;
|
||||
if (sayfa > 1) {
|
||||
return {
|
||||
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.`,
|
||||
alternates: { canonical: `/bolum/${slug}/sayfa/${sayfa}` },
|
||||
};
|
||||
}
|
||||
const enIyi =
|
||||
stats.minSira != null
|
||||
? ` En iyi taban sıralaması ${sayi(stats.minSira)}.`
|
||||
: "";
|
||||
return {
|
||||
title: `${bolum.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
|
||||
description: `${bolum.ad} 2025 yerleştirme verileri: ${stats.toplam} programın taban puanı ve sıralaması, ${stats.devlet} devlet / ${stats.vakif} vakıf.${enIyi} 2026 tercih rehberi.`,
|
||||
alternates: { canonical: `/bolum/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
const bolum = getBolumBySlug(slug);
|
||||
if (!bolum) notFound();
|
||||
|
||||
const tumProgramlar = [
|
||||
...bolum.lisansProgramlari,
|
||||
...bolum.onlisansProgramlari,
|
||||
];
|
||||
const toplamSayfa = bolumToplamSayfa(tumProgramlar.length);
|
||||
if (sayfa < 1 || sayfa > toplamSayfa) notFound();
|
||||
const dilim = tumProgramlar.slice(
|
||||
(sayfa - 1) * SAYFA_BOYU,
|
||||
sayfa * SAYFA_BOYU,
|
||||
);
|
||||
const ilkSayfa = sayfa === 1;
|
||||
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<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="mb-5 flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Ana sayfa
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
<Link
|
||||
href="/bolumler"
|
||||
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Bölümler
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
<span className="text-foreground">
|
||||
{ilkSayfa ? bolum.ad : `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}
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="universite-programlari" className="mt-8">
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<h2
|
||||
id="universite-programlari"
|
||||
className="font-heading text-xl font-bold text-foreground sm:text-2xl"
|
||||
>
|
||||
Üniversite programları
|
||||
</h2>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{sayi(tumProgramlar.length)} program
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BolumProgramListesi
|
||||
programlar={dilim.map((program) => ({
|
||||
...program,
|
||||
universiteSlug: uniSlugFromAd(program.universite),
|
||||
}))}
|
||||
bazAd={bolum.ad}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Sayfalama
|
||||
tabanYol={`/bolum/${slug}`}
|
||||
sayfa={sayfa}
|
||||
toplamSayfa={toplamSayfa}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
28
src/app/bolum/[slug]/page.tsx
Normal file
28
src/app/bolum/[slug]/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getAllBolumSlugs } from "@/lib/katalog";
|
||||
import { BolumIcerik, bolumSayfaMetadata } from "./bolum-icerik";
|
||||
|
||||
// Tüm bölümler build sırasında bilinir (veri ancak ingest + deploy ile
|
||||
// değişir); bilinmeyen slug getBolumBySlug'dan null dönüp notFound()'a düşer.
|
||||
// (dynamicParams cacheComponents ile uyumsuz olduğundan kullanılamıyor.)
|
||||
export function generateStaticParams() {
|
||||
return getAllBolumSlugs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return bolumSayfaMetadata(slug, 1);
|
||||
}
|
||||
|
||||
export default async function BolumPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <BolumIcerik slug={slug} sayfa={1} />;
|
||||
}
|
||||
47
src/app/bolum/[slug]/sayfa/[no]/page.tsx
Normal file
47
src/app/bolum/[slug]/sayfa/[no]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAllBolumler } from "@/lib/katalog";
|
||||
import {
|
||||
BolumIcerik,
|
||||
bolumSayfaMetadata,
|
||||
bolumToplamSayfa,
|
||||
} from "../../bolum-icerik";
|
||||
|
||||
// Yalnızca 1'den fazla sayfası olan bölümler için 2..N sayfaları üretilir;
|
||||
// 1. sayfa taban rota (/bolum/[slug]) olduğundan burada üretilmez.
|
||||
export function generateStaticParams() {
|
||||
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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function sayfaNo(no: string): number | null {
|
||||
const n = Number(no);
|
||||
return Number.isInteger(n) && n >= 2 ? n : null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug, no } = await params;
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) return {};
|
||||
return bolumSayfaMetadata(slug, n);
|
||||
}
|
||||
|
||||
export default async function BolumSayfaPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}) {
|
||||
const { slug, no } = await params;
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) notFound();
|
||||
return <BolumIcerik slug={slug} sayfa={n} />;
|
||||
}
|
||||
123
src/app/bolumler/page.tsx
Normal file
123
src/app/bolumler/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getAllBolumler, type BolumOzet } from "@/lib/katalog";
|
||||
import { KATEGORILER } from "@/lib/kategoriler";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026",
|
||||
description:
|
||||
"Üniversite bölümlerinin 2025 yerleştirme taban puanları ve başarı sıralamaları: lisans ve önlisans programları kategori kategori, 2026 tercihleri için.",
|
||||
alternates: { canonical: "/bolumler" },
|
||||
};
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
|
||||
return (
|
||||
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bolumler.map((b) => (
|
||||
<li key={b.slug}>
|
||||
<Link
|
||||
href={`/bolum/${b.slug}`}
|
||||
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
|
||||
>
|
||||
<span className="group-hover:underline">{b.ad}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{sayi(b.programSayisi)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function KategoriGruplari({
|
||||
bolumler,
|
||||
seviye,
|
||||
}: {
|
||||
bolumler: BolumOzet[];
|
||||
seviye: "lisans" | "onlisans";
|
||||
}) {
|
||||
const kalanlar = new Set(bolumler.map((b) => b.slug));
|
||||
const gruplar = KATEGORILER.map((kategori) => {
|
||||
const uyeler = bolumler.filter((b) =>
|
||||
(b.kategoriler as readonly string[]).includes(kategori),
|
||||
);
|
||||
for (const b of uyeler) kalanlar.delete(b.slug);
|
||||
return { kategori: kategori as string, uyeler };
|
||||
}).filter((g) => g.uyeler.length > 0);
|
||||
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
|
||||
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
|
||||
|
||||
return (
|
||||
<>
|
||||
{gruplar.map(({ kategori, uyeler }) => (
|
||||
<section key={`${seviye}-${kategori}`} className="mt-8">
|
||||
<h3 className="font-heading text-lg font-bold text-slate-900">
|
||||
{kategori}
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||
{sayi(uyeler.length)} bölüm
|
||||
</span>
|
||||
</h3>
|
||||
<BolumLinkleri bolumler={uyeler} />
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BolumlerPage() {
|
||||
const hepsi = getAllBolumler();
|
||||
const lisans = hepsi.filter((b) => b.lisans);
|
||||
const onlisans = hepsi.filter((b) => b.onlisans && !b.lisans);
|
||||
|
||||
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" },
|
||||
])}
|
||||
/>
|
||||
|
||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
|
||||
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
|
||||
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu
|
||||
görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026
|
||||
tercihleri için rehber niteliğindedir.
|
||||
</p>
|
||||
|
||||
<PagePixelDivider seed={29} className="mt-8" />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Lisans Bölümleri (4 Yıllık)
|
||||
</h2>
|
||||
<KategoriGruplari bolumler={lisans} seviye="lisans" />
|
||||
</section>
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Önlisans Bölümleri (2 Yıllık — TYT)
|
||||
</h2>
|
||||
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { GirisForm } from "./giris-form";
|
||||
import { GirisBaslik } from "./giris-baslik";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Giriş — KolayTercih",
|
||||
title: "Giriş",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default async function GirisPage({
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gizlilik ve KVKK — KolayTercih",
|
||||
title: "Gizlilik ve KVKK",
|
||||
description:
|
||||
"KolayTercih gizlilik politikası ve KVKK aydınlatma metni: hangi veriler toplanır, nasıl işlenir ve haklarınız nelerdir.",
|
||||
alternates: { canonical: "/gizlilik" },
|
||||
};
|
||||
|
||||
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İletişim — KolayTercih",
|
||||
title: "İletişim",
|
||||
description:
|
||||
"KolayTercih destek ekibine ulaş: sorular, geri bildirim ve iade talepleri için iletişim kanalları.",
|
||||
alternates: { canonical: "/iletisim" },
|
||||
};
|
||||
|
||||
export default function IletisimPage() {
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kullanım ve Satış Koşulları — KolayTercih",
|
||||
title: "Kullanım ve Satış Koşulları",
|
||||
description:
|
||||
"KolayTercih kullanım ve satış koşulları: hizmet kapsamı, ödeme, iade politikası ve sorumluluk sınırları.",
|
||||
alternates: { canonical: "/kosullar" },
|
||||
};
|
||||
|
||||
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@@ -9,6 +9,7 @@ import { HeaderGate } from "@/components/header-gate";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
import { DevPanel } from "@/components/dev/dev-panel";
|
||||
import { ScrollFlow } from "@/components/scroll-flow";
|
||||
import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo";
|
||||
import "./globals.css";
|
||||
|
||||
const calSans = Cal_Sans({
|
||||
@@ -42,9 +43,29 @@ const bricolage = Bricolage_Grotesque({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KolayTercih — Tercih Danışmanı",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "KolayTercih — Yapay Zekâ Destekli YKS Tercih Danışmanı",
|
||||
template: "%s — KolayTercih",
|
||||
},
|
||||
description:
|
||||
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.",
|
||||
applicationName: "KolayTercih",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "tr_TR",
|
||||
siteName: "KolayTercih",
|
||||
url: "/",
|
||||
title: "KolayTercih — Yapay Zekâ Destekli YKS Tercih Danışmanı",
|
||||
description:
|
||||
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. Yapay zekâ destekli tercih danışmanlığı.",
|
||||
},
|
||||
twitter: { card: "summary_large_image" },
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#f8fafc",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -55,9 +76,13 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="tr"
|
||||
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} h-full antialiased`}
|
||||
// `h-full` VERME — Lenis içerik yüksekliğini <html>'e bağlı ResizeObserver
|
||||
// ile izliyor; html sabit %100 kalınca lazy yüklenen içerik sonrası limit
|
||||
// bayatlıyor ve sayfa sonunda scroll kilitleniyor. html auto yükseklikte
|
||||
// içerikle büyümeli, viewport tabanı body'deki min-h-dvh'den gelir.
|
||||
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-slate-50">
|
||||
<body className="min-h-dvh flex flex-col bg-slate-50">
|
||||
{/* Site geneli progressive blur — üst ve alt */}
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
@@ -87,6 +112,8 @@ export default function RootLayout({
|
||||
<Toaster position="top-center" />
|
||||
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
|
||||
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}
|
||||
<JsonLd data={organizationJsonLd()} />
|
||||
<JsonLd data={webSiteJsonLd()} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,8 @@ import { SohbetClient, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Listem — KolayTercih",
|
||||
title: "Listem",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
|
||||
640
src/app/meraklisina/demo.tsx
Normal file
640
src/app/meraklisina/demo.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
"use client";
|
||||
|
||||
// /meraklisina'nın canlı anlatım katmanı: kullanıcı kendi sıralamasını girer,
|
||||
// dilim şeridi ve kademeli filtre hunisi o sıralamanın GERÇEK sayılarıyla
|
||||
// güncellenir. Provider sayfadaki metin bölümlerini (RSC children) sarar;
|
||||
// görseller context'ten okur, böylece tek girişle iki diyagram birden yaşar.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { DilimKey, SihirbazFacetleri } from "@/lib/db";
|
||||
|
||||
const TUR_SECENEKLERI = [
|
||||
{ deger: "say", etiket: "Sayısal" },
|
||||
{ deger: "ea", etiket: "Eşit Ağırlık" },
|
||||
{ deger: "soz", etiket: "Sözel" },
|
||||
{ deger: "dil", etiket: "Dil" },
|
||||
{ deger: "tyt", etiket: "TYT" },
|
||||
] as const;
|
||||
|
||||
export type DemoVeri = {
|
||||
facetler: SihirbazFacetleri;
|
||||
dilimToplam: Record<DilimKey, number>;
|
||||
};
|
||||
|
||||
type DemoDurum = {
|
||||
sira: number;
|
||||
tur: string;
|
||||
veri: DemoVeri;
|
||||
yukleniyor: boolean;
|
||||
// Huni seçimleri ve kademeli facet cevapları
|
||||
kategori: string | null;
|
||||
il: string | null;
|
||||
tip: "devlet" | "vakif" | null;
|
||||
kategoriFacet: SihirbazFacetleri | null; // ?kategori= cevabı (iller buradan)
|
||||
ilFacet: SihirbazFacetleri | null; // ?kategori&il cevabı (tipler buradan)
|
||||
kategoriSec: (k: string | null) => void;
|
||||
ilSec: (i: string | null) => void;
|
||||
tipSec: (t: "devlet" | "vakif" | null) => void;
|
||||
siraDegistir: (ham: string) => void;
|
||||
turDegistir: (t: string) => void;
|
||||
siraMetni: string;
|
||||
};
|
||||
|
||||
const DemoContext = createContext<DemoDurum | null>(null);
|
||||
|
||||
function useDemo(): DemoDurum {
|
||||
const ctx = useContext(DemoContext);
|
||||
if (!ctx) throw new Error("Demo bileşenleri SiralamaDemo içinde kullanılmalı");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async function facetGetir(
|
||||
sira: number,
|
||||
tur: string,
|
||||
secim: { kategori?: string; il?: string; dilimler?: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
||||
const params = new URLSearchParams({ sira: String(sira), tur });
|
||||
if (secim.kategori) params.append("kategori", secim.kategori);
|
||||
if (secim.il) params.append("il", secim.il);
|
||||
if (secim.dilimler) params.set("dilimler", "1");
|
||||
try {
|
||||
const res = await fetch(`/api/facetler?${params}`, { signal });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as {
|
||||
facetler: SihirbazFacetleri;
|
||||
dilimToplam?: Record<DilimKey, number>;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SiralamaDemo({
|
||||
baslangicSira,
|
||||
baslangicVeri,
|
||||
children,
|
||||
}: {
|
||||
baslangicSira: number;
|
||||
baslangicVeri: DemoVeri;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [siraMetni, setSiraMetni] = useState(
|
||||
baslangicSira.toLocaleString("tr-TR"),
|
||||
);
|
||||
const [sira, setSira] = useState(baslangicSira);
|
||||
const [tur, setTur] = useState("say");
|
||||
const [veri, setVeri] = useState(baslangicVeri);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [kategori, setKategori] = useState<string | null>(null);
|
||||
const [il, setIl] = useState<string | null>(null);
|
||||
const [tip, setTip] = useState<"devlet" | "vakif" | null>(null);
|
||||
const [kategoriFacet, setKategoriFacet] = useState<SihirbazFacetleri | null>(
|
||||
null,
|
||||
);
|
||||
const [ilFacet, setIlFacet] = useState<SihirbazFacetleri | null>(null);
|
||||
const zamanlayici = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Sıralama/tür değişti → ana veriyi tazele, huni seçimlerini sıfırla
|
||||
useEffect(() => {
|
||||
if (sira === baslangicSira && tur === "say" && veri === baslangicVeri) {
|
||||
return; // ilk render: sunucudan gelen veri zaten güncel
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||||
setYukleniyor(true);
|
||||
setKategori(null);
|
||||
setIl(null);
|
||||
setTip(null);
|
||||
setKategoriFacet(null);
|
||||
setIlFacet(null);
|
||||
facetGetir(sira, tur, { dilimler: true }, ctrl.signal)
|
||||
.then((cevap) => {
|
||||
if (!cevap?.dilimToplam) return;
|
||||
setVeri({ facetler: cevap.facetler, dilimToplam: cevap.dilimToplam });
|
||||
})
|
||||
.finally(() => setYukleniyor(false));
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sira, tur]);
|
||||
|
||||
// Kategori seçildi → o kategorinin illerini getir
|
||||
useEffect(() => {
|
||||
if (!kategori) return;
|
||||
const ctrl = new AbortController();
|
||||
facetGetir(sira, tur, { kategori }, ctrl.signal).then((cevap) => {
|
||||
if (cevap) setKategoriFacet(cevap.facetler);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kategori]);
|
||||
|
||||
// İl seçildi → kategori+il ile tipleri getir
|
||||
useEffect(() => {
|
||||
if (!kategori || !il) return;
|
||||
const ctrl = new AbortController();
|
||||
facetGetir(sira, tur, { kategori, il }, ctrl.signal).then((cevap) => {
|
||||
if (cevap) setIlFacet(cevap.facetler);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kategori, il]);
|
||||
|
||||
function siraDegistir(ham: string) {
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiraMetni(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
if (zamanlayici.current) clearTimeout(zamanlayici.current);
|
||||
const deger = Number(rakamlar);
|
||||
if (!deger || deger < 1 || deger > 4_000_000) return;
|
||||
zamanlayici.current = setTimeout(() => setSira(deger), 450);
|
||||
}
|
||||
|
||||
const durum: DemoDurum = {
|
||||
sira,
|
||||
tur,
|
||||
veri,
|
||||
yukleniyor,
|
||||
kategori,
|
||||
il,
|
||||
tip,
|
||||
kategoriFacet,
|
||||
ilFacet,
|
||||
kategoriSec: (k) => {
|
||||
setKategori(k);
|
||||
setIl(null);
|
||||
setTip(null);
|
||||
setKategoriFacet(null);
|
||||
setIlFacet(null);
|
||||
},
|
||||
ilSec: (i) => {
|
||||
setIl(i);
|
||||
setTip(null);
|
||||
setIlFacet(null);
|
||||
},
|
||||
tipSec: setTip,
|
||||
siraDegistir,
|
||||
turDegistir: setTur,
|
||||
siraMetni,
|
||||
};
|
||||
|
||||
return <DemoContext.Provider value={durum}>{children}</DemoContext.Provider>;
|
||||
}
|
||||
|
||||
/** Sıralama girişi + puan türü — iki diyagramı birden süren tek kumanda. */
|
||||
export function DemoKumanda() {
|
||||
const { siraMetni, siraDegistir, tur, turDegistir, sira, yukleniyor } =
|
||||
useDemo();
|
||||
return (
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||||
<label
|
||||
htmlFor="demo-sira"
|
||||
className="font-heading text-sm font-bold text-slate-900"
|
||||
>
|
||||
Kendi sıralamanı gir, aşağıdaki görseller sana göre değişsin
|
||||
</label>
|
||||
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
id="demo-sira"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={siraMetni}
|
||||
onChange={(e) => siraDegistir(e.target.value)}
|
||||
placeholder="ör. 85.000"
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-white px-4 text-base font-medium tabular-nums outline-none transition-colors focus:border-primary sm:w-44"
|
||||
/>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{TUR_SECENEKLERI.map((t) => (
|
||||
<button
|
||||
key={t.deger}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tur === t.deger}
|
||||
onClick={() => turDegistir(t.deger)}
|
||||
className={
|
||||
tur === t.deger
|
||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300"
|
||||
}
|
||||
>
|
||||
{t.etiket}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-2 text-xs text-slate-500 ${yukleniyor ? "animate-pulse" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{yukleniyor
|
||||
? "Hesaplanıyor…"
|
||||
: `Aşağıdaki tüm sayılar ${sira.toLocaleString("tr-TR")}. sıradaki bir aday için gerçek YÖK Atlas verisidir.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sıralama ekseni: girilen sıraya göre dilim sınırları ve gerçek adetler. */
|
||||
export function DilimSeridiCanli() {
|
||||
const { sira, veri, yukleniyor } = useDemo();
|
||||
const t = veri.dilimToplam;
|
||||
const dengeliUst = Math.round(sira * 1.4);
|
||||
const fmt = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
return (
|
||||
<figure className={`mt-6 ${yukleniyor ? "animate-pulse opacity-60" : ""}`}>
|
||||
<svg
|
||||
viewBox="0 0 640 168"
|
||||
role="img"
|
||||
aria-label={`Sıralama ekseni: ${fmt(sira)}. sıradaki aday için Hayal diliminde ${fmt(t.hayal)}, Dengeli diliminde ${fmt(t.dengeli)}, Garanti diliminde ${fmt(t.garanti)} program var.`}
|
||||
className="w-full"
|
||||
>
|
||||
{/* eksen */}
|
||||
<line
|
||||
x1="20"
|
||||
y1="86"
|
||||
x2="620"
|
||||
y2="86"
|
||||
stroke="currentColor"
|
||||
className="text-slate-300"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x="20" y="150" className="fill-slate-400 text-[11px]">
|
||||
1. sıra (en iyi)
|
||||
</text>
|
||||
<text
|
||||
x="620"
|
||||
y="150"
|
||||
textAnchor="end"
|
||||
className="fill-slate-400 text-[11px]"
|
||||
>
|
||||
taban sıralaması büyüdükçe →
|
||||
</text>
|
||||
|
||||
{/* hayal */}
|
||||
<rect x="20" y="70" width="150" height="32" rx="4" className="fill-red-100" />
|
||||
<text x="95" y="40" textAnchor="middle" className="fill-red-600 text-[12px] font-semibold">
|
||||
Hayal
|
||||
</text>
|
||||
<text x="95" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||||
{fmt(t.hayal)} program
|
||||
</text>
|
||||
<text x="95" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||||
tabanı senden iyi
|
||||
</text>
|
||||
|
||||
{/* aday işareti */}
|
||||
<line x1="172" y1="24" x2="172" y2="112" stroke="currentColor" className="text-slate-900" strokeWidth="2" strokeDasharray="4 3" />
|
||||
<text x="172" y="16" textAnchor="middle" className="fill-slate-900 text-[12px] font-bold">
|
||||
SEN · {fmt(sira)}.
|
||||
</text>
|
||||
|
||||
{/* dengeli */}
|
||||
<rect x="174" y="70" width="120" height="32" rx="4" className="fill-amber-100" />
|
||||
<text x="234" y="40" textAnchor="middle" className="fill-amber-600 text-[12px] font-semibold">
|
||||
Dengeli
|
||||
</text>
|
||||
<text x="234" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||||
{fmt(t.dengeli)} program
|
||||
</text>
|
||||
<text x="234" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||||
{fmt(sira)}. – {fmt(dengeliUst)}. arası taban
|
||||
</text>
|
||||
|
||||
{/* garanti */}
|
||||
<rect x="296" y="70" width="324" height="32" rx="4" className="fill-emerald-100" />
|
||||
<text x="458" y="40" textAnchor="middle" className="fill-emerald-700 text-[12px] font-semibold">
|
||||
Garanti
|
||||
</text>
|
||||
<text x="458" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||||
{fmt(t.garanti)} program
|
||||
</text>
|
||||
<text x="458" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||||
{fmt(dengeliUst + 1)}. sonrası — sonu yok, sayfaladıkça uzar
|
||||
</text>
|
||||
</svg>
|
||||
<figcaption className="mt-2 text-xs text-slate-500">
|
||||
Liste her dilimde{" "}
|
||||
<span className="font-medium text-slate-700">
|
||||
sana en yakın tabandan uzağa doğru
|
||||
</span>{" "}
|
||||
sıralanır; “Daha fazla göster” dedikçe uzaktakiler gelir.
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function HuniCubugu({
|
||||
etiket,
|
||||
adet,
|
||||
oran,
|
||||
}: {
|
||||
etiket: string;
|
||||
adet: number;
|
||||
oran: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="flex h-9 min-w-36 items-center justify-between gap-2 rounded-lg bg-primary/85 px-3 transition-[width] duration-300"
|
||||
style={{ width: `${Math.min(Math.max(oran * 100, 36), 100)}%` }}
|
||||
>
|
||||
<span className="truncate text-xs font-medium text-white">{etiket}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-white">
|
||||
{adet.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecimCipleri<T extends { anahtar: string; etiket: string; adet: number }>({
|
||||
secenekler,
|
||||
secili,
|
||||
onSec,
|
||||
}: {
|
||||
secenekler: T[];
|
||||
secili: string | null;
|
||||
onSec: (anahtar: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{secenekler.map((s) => (
|
||||
<button
|
||||
key={s.anahtar}
|
||||
type="button"
|
||||
aria-pressed={secili === s.anahtar}
|
||||
onClick={() => onSec(secili === s.anahtar ? null : s.anahtar)}
|
||||
className={
|
||||
secili === s.anahtar
|
||||
? "cursor-pointer rounded-full border border-orange-500 bg-orange-500 px-3 py-1.5 text-xs font-medium text-white"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300"
|
||||
}
|
||||
>
|
||||
{s.etiket}
|
||||
<span className="ml-1 text-[10px] opacity-70 tabular-nums">
|
||||
{s.adet.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AI listesinin dilim iskeleti: üstte hayal, ortada dengeli, sonda garanti.
|
||||
// Ürünün risk renkleriyle boyanır; sayılar prompt'taki kuralın orta değeri.
|
||||
const LISTE_ISKELETI: { dilim: string; adet: number; renk: string }[] = [
|
||||
{ dilim: "hayal", adet: 5, renk: "bg-red-400" },
|
||||
{ dilim: "dengeli", adet: 13, renk: "bg-amber-400" },
|
||||
{ dilim: "garanti", adet: 6, renk: "bg-emerald-500" },
|
||||
];
|
||||
|
||||
/** Huninin finali: kalan havuzun yapay zekâya gidişi ve 24'lük listenin
|
||||
iskeleti. Havuz 24'ün altına düşerse gevşetme kuralını canlı gösterir. */
|
||||
function YapayZekaAsamasi({ havuz }: { havuz: number }) {
|
||||
const dar = havuz < 24;
|
||||
return (
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
Sonra sıra yapay zekâda
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs leading-5 text-slate-600">
|
||||
Seçimlerinden geriye kalan{" "}
|
||||
<span className="font-bold tabular-nums text-slate-900">
|
||||
{havuz.toLocaleString("tr-TR")} programlık havuz
|
||||
</span>{" "}
|
||||
yapay zekâya gider; o da bu havuzdan tam 24 tercih seçer ve şu iskelete
|
||||
oturtur:
|
||||
</p>
|
||||
|
||||
{/* 24 hücrelik liste şeridi — renk + metin birlikte (renk tek başına
|
||||
anlam taşımaz) */}
|
||||
<div
|
||||
role="img"
|
||||
aria-label="24 tercihlik listenin yapısı: en üstte yaklaşık 5 hayal, ortada 13 dengeli, sonda 6 garanti tercih."
|
||||
className="mt-3"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{LISTE_ISKELETI.flatMap((b) =>
|
||||
Array.from({ length: b.adet }, (_, i) => (
|
||||
<span
|
||||
key={`${b.dilim}-${i}`}
|
||||
className={`h-6 min-w-0 flex-1 rounded ${b.renk}`}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[11px] text-slate-500">
|
||||
<span>
|
||||
üstte 4–6 <span className="font-semibold text-red-600">hayal</span>
|
||||
</span>
|
||||
<span>
|
||||
ortada 12–14{" "}
|
||||
<span className="font-semibold text-amber-600">dengeli</span>
|
||||
</span>
|
||||
<span>
|
||||
sonda 5–7{" "}
|
||||
<span className="font-semibold text-emerald-600">garanti</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-1 text-xs leading-5 text-slate-600">
|
||||
<li>
|
||||
· Havuzda olmayan bir bölümü yazamaz — listedeki her satırın kimliği
|
||||
havuzla karşılaştırılır, uymayan liste yeniden ürettirilir.
|
||||
</li>
|
||||
<li>
|
||||
· Her satıra tek cümlelik gerekçe ve bir risk notu ekler; sıraya neyi
|
||||
neden koyduğu görünür.
|
||||
</li>
|
||||
<li>
|
||||
· Önceliklerin (iş garantisi, maaş, prestij…) hangi programın öne
|
||||
geçeceğini belirler.
|
||||
</li>
|
||||
<li>
|
||||
· Havuz 24'ün altına düşerse filtreler adım adım gevşetilir —
|
||||
önce il, sonra alan — ve bu, listende açıkça belirtilir.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{dar ? (
|
||||
<p className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
|
||||
Bu seçimle havuz 24'ün altına düştü. Gerçek sihirbaz bu durumda
|
||||
filtreleri adım adım gevşetir — önce il, sonra alan tercihi esnetilir
|
||||
— ve bunu listende açıkça belirtir; liste her zaman 24'e
|
||||
tamamlanır.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kademeli filtre hunisi: gerçek sihirbaz mantığının oynanabilir kopyası. */
|
||||
export function HuniCanli() {
|
||||
const {
|
||||
veri,
|
||||
yukleniyor,
|
||||
kategori,
|
||||
il,
|
||||
tip,
|
||||
kategoriFacet,
|
||||
ilFacet,
|
||||
kategoriSec,
|
||||
ilSec,
|
||||
tipSec,
|
||||
} = useDemo();
|
||||
|
||||
const toplam = veri.facetler.toplam;
|
||||
const kategoriAdet =
|
||||
kategori != null
|
||||
? (veri.facetler.kategoriler.find((k) => k.ad === kategori)?.adet ?? 0)
|
||||
: null;
|
||||
const ilAdet =
|
||||
il != null && kategoriFacet
|
||||
? (kategoriFacet.iller.find((i) => i.il === il)?.adet ?? 0)
|
||||
: null;
|
||||
const tipAdet =
|
||||
tip != null && ilFacet
|
||||
? (ilFacet.uniturler.find((u) => u.grup === tip)?.adet ?? 0)
|
||||
: null;
|
||||
|
||||
const kategoriSecenekleri = veri.facetler.kategoriler.slice(0, 6).map((k) => ({
|
||||
anahtar: k.ad,
|
||||
etiket: k.ad,
|
||||
adet: k.adet,
|
||||
}));
|
||||
const ilSecenekleri = (kategoriFacet?.iller ?? []).slice(0, 6).map((i) => ({
|
||||
anahtar: i.il,
|
||||
etiket: i.il.toLocaleLowerCase("tr-TR"),
|
||||
adet: i.adet,
|
||||
}));
|
||||
const tipSecenekleri = (ilFacet?.uniturler ?? []).map((u) => ({
|
||||
anahtar: u.grup,
|
||||
etiket: u.grup === "devlet" ? "Devlet" : "Vakıf",
|
||||
adet: u.adet,
|
||||
}));
|
||||
|
||||
// Yapay zekâya giden havuz: huninin en derin (en son seçilmiş) katmanı
|
||||
const havuz = tipAdet ?? ilAdet ?? kategoriAdet ?? toplam;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-5 ${
|
||||
yukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Huni çubukları — her seçim bir çubuk daha ekler */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0" />
|
||||
<HuniCubugu
|
||||
etiket="Sıralamanla açılan tüm programlar"
|
||||
adet={toplam}
|
||||
oran={1}
|
||||
/>
|
||||
</div>
|
||||
{kategori != null && kategoriAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
1.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`“${kategori}” seçince`}
|
||||
adet={kategoriAdet}
|
||||
oran={toplam > 0 ? kategoriAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{il != null && ilAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
2.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`+ “${il.toLocaleLowerCase("tr-TR")}” seçince`}
|
||||
adet={ilAdet}
|
||||
oran={toplam > 0 ? ilAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{tip != null && tipAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
3.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`+ “${tip === "devlet" ? "Devlet" : "Vakıf"}” seçince`}
|
||||
adet={tipAdet}
|
||||
oran={toplam > 0 ? tipAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Seçim adımları — sihirbazın küçük, oynanabilir hali */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-4">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
1. Bir alan seç
|
||||
</p>
|
||||
<SecimCipleri
|
||||
secenekler={kategoriSecenekleri}
|
||||
secili={kategori}
|
||||
onSec={kategoriSec}
|
||||
/>
|
||||
</div>
|
||||
{kategori != null ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
2. Bir il seç{" "}
|
||||
<span className="font-normal text-slate-400">
|
||||
(iller artık yalnızca “{kategori}” programlarını sayıyor)
|
||||
</span>
|
||||
</p>
|
||||
{ilSecenekleri.length > 0 ? (
|
||||
<SecimCipleri
|
||||
secenekler={ilSecenekleri}
|
||||
secili={il}
|
||||
onSec={ilSec}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">Yükleniyor…</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{kategori != null && il != null ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
3. Üniversite tipi seç{" "}
|
||||
<span className="font-normal text-slate-400">
|
||||
(sayılar hem alanı hem ili hesaba katıyor)
|
||||
</span>
|
||||
</p>
|
||||
{tipSecenekleri.length > 0 ? (
|
||||
<SecimCipleri
|
||||
secenekler={tipSecenekleri}
|
||||
secili={tip}
|
||||
onSec={(t) => tipSec(t as "devlet" | "vakif" | null)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">Yükleniyor…</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<YapayZekaAsamasi havuz={havuz} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/app/meraklisina/page.tsx
Normal file
189
src/app/meraklisina/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { dilimAra, rankWindowFacets, type DilimKey } from "@/lib/db";
|
||||
import {
|
||||
DemoKumanda,
|
||||
DilimSeridiCanli,
|
||||
HuniCanli,
|
||||
SiralamaDemo,
|
||||
type DemoVeri,
|
||||
} from "./demo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Meraklısına: KolayTercih nasıl çalışır?",
|
||||
alternates: { canonical: "/meraklisina" },
|
||||
description:
|
||||
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, kendi sıralamanla deneyebileceğin canlı görsellerle.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
|
||||
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
|
||||
* Görseller canlıdır: kullanıcı sıralamasını girer, dilim şeridi ve filtre
|
||||
* hunisi o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx).
|
||||
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
|
||||
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
|
||||
*/
|
||||
|
||||
// Demo ilk açılışta bu sıralamayla dolu gelir; SSR'da gerçek veriyle render
|
||||
// edilir ki kullanıcı girmeden de sayfa anlamlı olsun.
|
||||
const VARSAYILAN_SIRA = 100_000;
|
||||
|
||||
export default function MeraklisinaPage() {
|
||||
const dilimToplam = Object.fromEntries(
|
||||
(["hayal", "dengeli", "garanti"] as DilimKey[]).map((d) => [
|
||||
d,
|
||||
dilimAra(VARSAYILAN_SIRA, "say", d, { limit: 0 }).toplam,
|
||||
]),
|
||||
) as Record<DilimKey, number>;
|
||||
const baslangicVeri: DemoVeri = {
|
||||
facetler: rankWindowFacets(VARSAYILAN_SIRA, "say"),
|
||||
dilimToplam,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<p className="text-sm font-semibold tracking-wide text-primary">
|
||||
Meraklısına
|
||||
</p>
|
||||
<h1 className="mt-1 font-heading text-3xl font-bold">
|
||||
Sıralamanı girince arka planda ne oluyor?
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Sihir yok, kara kutu yok: KolayTercih'in önerdiği her satır resmî
|
||||
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
|
||||
Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller
|
||||
canlı: kendi sıralamanı gir, sayılar sana göre değişsin.
|
||||
</p>
|
||||
</Parallax>
|
||||
|
||||
<PagePixelDivider seed={73} className="mt-6" />
|
||||
|
||||
<SiralamaDemo
|
||||
baslangicSira={VARSAYILAN_SIRA}
|
||||
baslangicVeri={baslangicVeri}
|
||||
>
|
||||
<Parallax
|
||||
fromStart
|
||||
strength={4}
|
||||
className="mt-8 space-y-10 text-sm leading-7 text-slate-700"
|
||||
>
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
1. Her şey taban sıralamasıyla başlar
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Bir bölümün <strong>taban sıralaması</strong>, geçen yıl o bölüme
|
||||
yerleşen <em>son</em>{" "}
|
||||
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
|
||||
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
|
||||
yerleşebilirdin demektir. Sistemde her programın en güncel taban
|
||||
sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
|
||||
bir önceki yılınki kullanılır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<DemoKumanda />
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
2. Programlar üç dilime ayrılır
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Sıralamanı girdiğinde tüm programlar senin konumuna göre üç kümeye
|
||||
ayrılır: tabanı senden <strong>daha iyi</strong> olanlar{" "}
|
||||
<span className="font-semibold text-red-600">Hayal</span> (şansını
|
||||
denediğin satırlar), tabanı sıralamana{" "}
|
||||
<strong>yakın</strong> olanlar{" "}
|
||||
<span className="font-semibold text-amber-600">Dengeli</span>{" "}
|
||||
(listenin bel kemiği — sıralamanın yaklaşık yüzde kırk fazlasına
|
||||
kadar pay bırakılır), tabanı{" "}
|
||||
<strong>belirgin şekilde altında</strong> kalan her şey ise{" "}
|
||||
<span className="font-semibold text-emerald-600">Garanti</span>{" "}
|
||||
dilimidir.
|
||||
</p>
|
||||
<DilimSeridiCanli />
|
||||
<p className="mt-4">
|
||||
Garanti diliminin bir sonu yok: 87. sıradaki bir adayın da 500
|
||||
bininci sıradaki bir adayın da ulaşabildiği <em>bütün</em>{" "}
|
||||
programlar listededir. Liste sana en yakın tabandan başlar; aşağı
|
||||
indikçe ve “Daha fazla göster”e bastıkça senden
|
||||
uzaklaşan seçenekler gelir. Böylece ilk gördüklerin her zaman en
|
||||
isabetli adaylardır ama hiçbir seçenek senden saklanmaz.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
3. Risk renkleri her yerde aynı dili konuşur
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Tabloda, haritada ve yapay zekâ listesinde gördüğün nokta ve
|
||||
renkler tek bir kurala bağlıdır:{" "}
|
||||
<span className="font-semibold text-emerald-600">yeşil</span>{" "}
|
||||
tabanı sıralamanın belirgin altında (güvenli),{" "}
|
||||
<span className="font-semibold text-amber-600">sarı</span>{" "}
|
||||
sıralamana yakın (az riskli),{" "}
|
||||
<span className="font-semibold text-red-600">kırmızı</span>{" "}
|
||||
tabanı senden iyi (riskli). Renk hangi sayfada olursa olsun aynı
|
||||
hesaptan çıkar; bir satır sekme değiştirdi diye renk değiştirmez.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
4. Sihirbaz süzer, yapay zekâ kurar
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
“Listemi oluştur” sihirbazındaki üç adım birbirinden
|
||||
bağımsız değildir. İlk adımda yalnızca{" "}
|
||||
<strong>sıralamanla ulaşabildiğin</strong>{" "}
|
||||
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
|
||||
program sayısına göre yeniden hesaplanır; üçüncü adımdaki
|
||||
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
|
||||
Adımlar bitince elde kalan gerçek programlar bir{" "}
|
||||
<strong>aday havuzu</strong>{" "}
|
||||
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
||||
verilir. Aşağıda kendin dene — her seçimin havuzu nasıl
|
||||
daralttığını ve havuzun 24'lük listeye nasıl dönüştüğünü huni
|
||||
canlı gösterir.
|
||||
</p>
|
||||
<HuniCanli />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
5. Dürüst olalım: bu bir tahmin aracıdır
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Tüm bu hesaplar <strong>geçen yılların</strong>{" "}
|
||||
yerleştirme sonuçlarına dayanır. Kontenjanlar, tercih davranışları
|
||||
ve puanlar her yıl değişir; geçen yıl “garanti”
|
||||
görünen bir bölüm bu yıl dolabilir. Bu yüzden satırlarda son beş
|
||||
yılın taban seyrini de gösteririz — çizgi yukarı tırmanıyorsa
|
||||
bölüm zorlaşıyor demektir. KolayTercih karar <em>destek</em>{" "}
|
||||
aracıdır; listenin son hâli ve sorumluluğu her zaman sana aittir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
|
||||
<p className="font-heading text-base font-bold text-slate-900">
|
||||
Kuralları öğrendin — şimdi kendi sıralamanla dene.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Sıralamanı gir
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</Parallax>
|
||||
</SiralamaDemo>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ödeme Sonucu — KolayTercih",
|
||||
title: "Ödeme Sonucu",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function OdemeSonucPage({
|
||||
|
||||
1
src/app/opengraph-image.alt.txt
Normal file
1
src/app/opengraph-image.alt.txt
Normal file
@@ -0,0 +1 @@
|
||||
KolayTercih — YKS tercihin, gerçek YÖK Atlas verisiyle. Sıralamanı gir, dengeli 24 tercihlik listeni al.
|
||||
BIN
src/app/opengraph-image.png
Normal file
BIN
src/app/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
@@ -44,6 +45,17 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
JsonLd,
|
||||
faqPageJsonLd,
|
||||
softwareApplicationJsonLd,
|
||||
} from "@/lib/seo";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: { canonical: "/" },
|
||||
};
|
||||
|
||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
maximumFractionDigits: 0,
|
||||
@@ -170,6 +182,8 @@ export default function Home() {
|
||||
className="flex min-h-screen flex-col bg-slate-50 text-slate-900"
|
||||
>
|
||||
<main className="flex-1">
|
||||
<JsonLd data={faqPageJsonLd(faqs)} />
|
||||
<JsonLd data={softwareApplicationJsonLd()} />
|
||||
{/* Hero */}
|
||||
<section className="relative isolate overflow-hidden">
|
||||
<div
|
||||
@@ -293,7 +307,20 @@ export default function Home() {
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<Parallax strength={16}>
|
||||
<Reveal className="flex flex-col items-center gap-4 text-center">
|
||||
<SectionEyebrow>Nasıl çalışır?</SectionEyebrow>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<SectionEyebrow>Nasıl çalışır?</SectionEyebrow>
|
||||
{/* Kuralların şeffaf anlatımına köprü: /meraklisina */}
|
||||
<Link
|
||||
href="/meraklisina"
|
||||
className="group inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Meraklısına
|
||||
<ArrowUpRight
|
||||
className="size-3.5 transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="font-heading text-3xl font-bold sm:text-4xl">
|
||||
Üç adımda listen hazır
|
||||
</h2>
|
||||
@@ -515,41 +542,7 @@ export default function Home() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="pb-32 pt-4">
|
||||
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
|
||||
<p>
|
||||
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
|
||||
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
|
||||
Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
|
||||
</p>
|
||||
<nav
|
||||
aria-label="Yasal sayfalar"
|
||||
className="mt-4 flex items-center justify-center gap-4"
|
||||
>
|
||||
<Link
|
||||
href="/gizlilik"
|
||||
className="hover:text-slate-700 hover:underline"
|
||||
>
|
||||
Gizlilik & KVKK
|
||||
</Link>
|
||||
<Link
|
||||
href="/kosullar"
|
||||
className="hover:text-slate-700 hover:underline"
|
||||
>
|
||||
Kullanım & iade koşulları
|
||||
</Link>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
className="hover:text-slate-700 hover:underline"
|
||||
>
|
||||
İletişim
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SatinAlForm } from "./satin-al-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Dönemi Paketi — KolayTercih",
|
||||
title: "Tercih Dönemi Paketi",
|
||||
description:
|
||||
"Tek seferlik Tercih Dönemi Paketi: yapay zekâ destekli kişisel 24 tercihlik liste, risk analizi, liste revizyonları ve soru hakkı. Abonelik yok.",
|
||||
alternates: { canonical: "/paket" },
|
||||
};
|
||||
|
||||
function tl(kurus: number) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { YazdirButonu } from "./yazdir-butonu";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Raporu (Yazdır) — KolayTercih",
|
||||
title: "Tercih Raporu (Yazdır)",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const DILIM_LABEL: Record<string, string> = {
|
||||
|
||||
220
src/app/rehber/[slug]/opengraph-image.tsx
Normal file
220
src/app/rehber/[slug]/opengraph-image.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { getRehber } from "@/lib/rehber";
|
||||
import {
|
||||
rehberBasliginiBol,
|
||||
rehberMetniniParcala,
|
||||
} from "@/components/rehber-kapak";
|
||||
|
||||
export const alt = "KolayTercih Tercih Rehberi yazı kapağı";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default async function RehberOpenGraphImage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const yazi = getRehber(slug);
|
||||
const baslik = yazi?.baslik ?? "Tercih Rehberi";
|
||||
const { vurgu, kalan } = rehberBasliginiBol(baslik);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
background: "white",
|
||||
color: "#2563eb",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
[960, 50, 0.16], [980, 50, 0.28], [1020, 50, 0.12],
|
||||
[940, 70, 0.24], [980, 70, 0.12], [1000, 70, 0.3],
|
||||
[1060, 70, 0.18], [960, 90, 0.12], [1000, 90, 0.2],
|
||||
[1040, 90, 0.1], [1080, 90, 0.26], [1080, 110, 0.14],
|
||||
[1120, 110, 0.22], [1140, 110, 0.12], [60, 490, 0.12],
|
||||
[100, 490, 0.25], [120, 490, 0.14], [40, 510, 0.24],
|
||||
[80, 510, 0.12], [120, 510, 0.3], [160, 510, 0.16],
|
||||
[60, 530, 0.14], [100, 530, 0.22], [140, 530, 0.1],
|
||||
[180, 530, 0.27],
|
||||
].map(([left, top, opacity], i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
left,
|
||||
top,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
background: "#2563eb",
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
left: 1040,
|
||||
top: 50,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
background: "#ff5a00",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
left: 80,
|
||||
top: 530,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
background: "#ff5a00",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px solid rgba(37,99,235,0.15)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
padding: "58px 68px 52px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563eb",
|
||||
}}
|
||||
>
|
||||
<span>Tercih Rehberi</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
maxWidth: 980,
|
||||
fontSize: baslik.length > 70 ? 48 : baslik.length > 52 ? 54 : 62,
|
||||
fontWeight: 300,
|
||||
lineHeight: 1.02,
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#2563eb",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
color: "#ff5a00",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.04em",
|
||||
}}
|
||||
>
|
||||
{rehberMetniniParcala(vurgu).map((parca, i) =>
|
||||
/^\d/.test(parca) ? (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
fontStyle: "italic",
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{parca}
|
||||
</span>
|
||||
) : (
|
||||
parca
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
{kalan ? (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
marginLeft: /^\s/.test(kalan) ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
{rehberMetniniParcala(kalan.trimStart()).map((parca, i) =>
|
||||
/^\d/.test(parca) ? (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
fontStyle: "italic",
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{parca}
|
||||
</span>
|
||||
) : (
|
||||
parca
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="38"
|
||||
height="38"
|
||||
viewBox="0 0 180 180"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<rect x="27" y="56" width="57" height="57" rx="13" fill="#3b82f6" />
|
||||
<rect x="91" y="29" width="56" height="56" rx="13" fill="#fdc7a7" />
|
||||
<rect x="91" y="95" width="56" height="56" rx="13" fill="#ff5a00" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
marginLeft: 2,
|
||||
fontSize: 24,
|
||||
letterSpacing: "-0.05em",
|
||||
color: "#2563eb",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 300 }}>Kolay</span>
|
||||
<span style={{ fontWeight: 700 }}>Tercih</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
137
src/app/rehber/[slug]/page.tsx
Normal file
137
src/app/rehber/[slug]/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getAllRehberSlugs, getAllRehberler, getRehber } from "@/lib/rehber";
|
||||
import { JsonLd, articleJsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { RehberKapak } from "@/components/rehber-kapak";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export function generateStaticParams() {
|
||||
const slugs = getAllRehberSlugs();
|
||||
if (slugs.length === 0) {
|
||||
throw new Error("rehber: content/rehber altında hiç makale yok");
|
||||
}
|
||||
return slugs.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const yazi = getRehber(slug);
|
||||
if (!yazi) return {};
|
||||
return {
|
||||
title: yazi.baslik,
|
||||
description: yazi.aciklama,
|
||||
alternates: { canonical: `/rehber/${slug}` },
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: yazi.baslik,
|
||||
description: yazi.aciklama,
|
||||
publishedTime: yazi.tarih,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RehberYaziPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const yazi = getRehber(slug);
|
||||
if (!yazi) notFound();
|
||||
|
||||
const digerYazilar = getAllRehberler()
|
||||
.filter((y) => y.slug !== slug)
|
||||
.slice(0, 4);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-3xl px-4 py-12 sm:py-16">
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: yazi.baslik,
|
||||
description: yazi.aciklama,
|
||||
path: `/rehber/${slug}`,
|
||||
datePublished: yazi.tarih,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
{ name: "Rehber", path: "/rehber" },
|
||||
{ name: yazi.baslik, path: `/rehber/${slug}` },
|
||||
])}
|
||||
/>
|
||||
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
className="flex items-center gap-1 text-xs text-slate-500"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary hover:underline">
|
||||
Ana Sayfa
|
||||
</Link>
|
||||
<ChevronRight className="size-3" aria-hidden />
|
||||
<Link href="/rehber" className="hover:text-primary hover:underline">
|
||||
Rehber
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<article>
|
||||
<RehberKapak baslik={yazi.baslik} className="mt-6 shadow-sm" />
|
||||
<h1 className="mt-8 font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
{yazi.baslik}
|
||||
</h1>
|
||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||
{yazi.aciklama}
|
||||
</p>
|
||||
<div
|
||||
className="mt-8 text-[15px] leading-7 text-slate-700
|
||||
[&_h2]:mt-10 [&_h2]:font-heading [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-slate-900
|
||||
[&_h3]:mt-6 [&_h3]:font-heading [&_h3]:text-base [&_h3]:font-bold [&_h3]:text-slate-900
|
||||
[&_p]:mt-3
|
||||
[&_a]:font-medium [&_a]:text-primary hover:[&_a]:underline
|
||||
[&_strong]:font-semibold [&_strong]:text-slate-900
|
||||
[&_ul]:mt-3 [&_ul]:list-disc [&_ul]:space-y-1.5 [&_ul]:pl-6
|
||||
[&_ol]:mt-3 [&_ol]:list-decimal [&_ol]:space-y-1.5 [&_ol]:pl-6
|
||||
[&_table]:mt-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm
|
||||
[&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold
|
||||
[&_td]:border [&_td]:border-slate-200 [&_td]:px-3 [&_td]:py-2
|
||||
[&_blockquote]:mt-4 [&_blockquote]:border-l-4 [&_blockquote]:border-primary/30 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-slate-600"
|
||||
dangerouslySetInnerHTML={{ __html: yazi.html }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
|
||||
{digerYazilar.length > 0 ? (
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
Diğer rehber yazıları
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{digerYazilar.map((y) => (
|
||||
<li key={y.slug}>
|
||||
<Link
|
||||
href={`/rehber/${y.slug}`}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{y.baslik}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/app/rehber/page.tsx
Normal file
77
src/app/rehber/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getAllRehberler } from "@/lib/rehber";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { RehberKapak } from "@/components/rehber-kapak";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Rehberi 2026 — YKS Tercih Dönemi Yazıları",
|
||||
description:
|
||||
"YKS tercih dönemi rehberi: tercih listesi nasıl yapılır, ölü tercih nedir, ek yerleştirme nasıl işler, sıralama mı puan mı — hepsi öğrenci diliyle.",
|
||||
alternates: { canonical: "/rehber" },
|
||||
};
|
||||
|
||||
export default function RehberIndexPage() {
|
||||
const yazilar = getAllRehberler();
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-12 sm:py-16">
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
{ name: "Rehber", path: "/rehber" },
|
||||
])}
|
||||
/>
|
||||
|
||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
Tercih Rehberi
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
Tercih döneminin en çok sorulan soruları, veriye dayalı ve öğrenci
|
||||
diliyle. Panik yok, pazarlama yok — sadece işine yarayacak bilgi.
|
||||
</p>
|
||||
|
||||
<PagePixelDivider seed={11} className="mt-8" />
|
||||
|
||||
<ul className="mt-8 columns-1 gap-5 sm:columns-2 lg:columns-3">
|
||||
{yazilar.map((y) => (
|
||||
<li key={y.slug} className="mb-5 break-inside-avoid">
|
||||
<Link
|
||||
href={`/rehber/${y.slug}`}
|
||||
className="block overflow-hidden rounded-3xl border border-slate-200 bg-white p-2 shadow-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
|
||||
>
|
||||
<RehberKapak
|
||||
baslik={y.baslik}
|
||||
compact
|
||||
className="rounded-[1.15rem]"
|
||||
/>
|
||||
<div className="px-3 pb-3 pt-4">
|
||||
<p className="text-sm leading-6 text-slate-600">
|
||||
{y.aciklama}
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1.5 font-bricolage text-sm font-semibold text-primary">
|
||||
Yazıyı oku
|
||||
<ArrowRight
|
||||
className="size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mx-auto mt-16 max-w-3xl">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/app/robots.ts
Normal file
15
src/app/robots.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
// /sonuc bilinçli olarak ENGELLENMEDİ: sayfa noindex meta taşıyor ve
|
||||
// robots.txt engeli o meta'nın görülmesini imkânsız kılardı.
|
||||
disallow: ["/api/", "/listem", "/odeme/", "/rapor/", "/giris"],
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
40
src/app/sitemap.ts
Normal file
40
src/app/sitemap.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
import { getAllBolumSlugs, getAllUniversiteSlugs } from "@/lib/katalog";
|
||||
import { getAllRehberler } from "@/lib/rehber";
|
||||
|
||||
// Bilinçli olarak sitemap DIŞI: /sonuc (noindex), /giris, /listem,
|
||||
// /odeme/sonuc, /rapor/yazdir (auth/utility sayfaları).
|
||||
const staticEntries: MetadataRoute.Sitemap = [
|
||||
{ url: `${SITE_URL}/`, priority: 1, changeFrequency: "weekly" },
|
||||
{ url: `${SITE_URL}/bolumler`, priority: 0.8, changeFrequency: "weekly" },
|
||||
{ url: `${SITE_URL}/universiteler`, priority: 0.8, changeFrequency: "weekly" },
|
||||
{ url: `${SITE_URL}/rehber`, priority: 0.8, changeFrequency: "weekly" },
|
||||
{ url: `${SITE_URL}/paket`, priority: 0.7, changeFrequency: "monthly" },
|
||||
{ url: `${SITE_URL}/meraklisina`, priority: 0.6, changeFrequency: "monthly" },
|
||||
{ url: `${SITE_URL}/iletisim`, priority: 0.3, changeFrequency: "yearly" },
|
||||
{ url: `${SITE_URL}/gizlilik`, priority: 0.2, changeFrequency: "yearly" },
|
||||
{ url: `${SITE_URL}/kosullar`, priority: 0.2, changeFrequency: "yearly" },
|
||||
];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const bolumler: MetadataRoute.Sitemap = getAllBolumSlugs().map((slug) => ({
|
||||
url: `${SITE_URL}/bolum/${slug}`,
|
||||
priority: 0.7,
|
||||
changeFrequency: "monthly",
|
||||
}));
|
||||
const universiteler: MetadataRoute.Sitemap = getAllUniversiteSlugs().map(
|
||||
(slug) => ({
|
||||
url: `${SITE_URL}/universite/${slug}`,
|
||||
priority: 0.6,
|
||||
changeFrequency: "monthly",
|
||||
}),
|
||||
);
|
||||
const rehberler: MetadataRoute.Sitemap = getAllRehberler().map((y) => ({
|
||||
url: `${SITE_URL}/rehber/${y.slug}`,
|
||||
lastModified: y.tarih,
|
||||
priority: 0.7,
|
||||
changeFrequency: "monthly",
|
||||
}));
|
||||
return [...staticEntries, ...bolumler, ...universiteler, ...rehberler];
|
||||
}
|
||||
@@ -19,10 +19,15 @@ import type { MevcutRapor } from "./liste-paneli";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur — KolayTercih",
|
||||
title: "Tercih Listeni Kur",
|
||||
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
|
||||
// link değeri follow ile geri akar. robots.txt'te ENGELLEME — noindex'in
|
||||
// görülebilmesi için sayfa taranabilir kalmalı.
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||||
@@ -132,15 +137,22 @@ export default async function SonucPage({
|
||||
</p>
|
||||
<PagePixelDivider seed={61} className="mt-7" />
|
||||
|
||||
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
|
||||
liste store'una abone olduğundan tablodan "+" ile eklenenler
|
||||
buraya canlı yansır. */}
|
||||
<div className="mt-8">
|
||||
<ManuelHarita />
|
||||
</div>
|
||||
|
||||
{/* AI'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana
|
||||
gövdesi; puan türü seçici de listenin başında yaşar */}
|
||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||
|
||||
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
|
||||
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
|
||||
satacak bir şey kalmadığından gösterilmez */}
|
||||
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
|
||||
|
||||
{/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana
|
||||
gövdesi; puan türü seçici de listenin başında yaşar */}
|
||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
|
||||
sayfanın en altında yaşar */}
|
||||
<SihirbazBolumu
|
||||
|
||||
@@ -504,6 +504,8 @@ export function SihirbazBolumu({
|
||||
<SihirbazModal
|
||||
acik={acik}
|
||||
onAcikDegisti={acikDegisti}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
baslangicSecimler={
|
||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
|
||||
export function SihirbazModal({
|
||||
acik,
|
||||
onAcikDegisti,
|
||||
sira,
|
||||
tur,
|
||||
facetler,
|
||||
baslangicSecimler,
|
||||
sonButonEtiketi,
|
||||
@@ -25,6 +27,8 @@ export function SihirbazModal({
|
||||
}: {
|
||||
acik: boolean;
|
||||
onAcikDegisti: (acik: boolean) => void;
|
||||
sira: number;
|
||||
tur: string;
|
||||
facetler: SihirbazFacetleri;
|
||||
baslangicSecimler?: SihirbazSecimleri | null;
|
||||
sonButonEtiketi: string;
|
||||
@@ -41,6 +45,8 @@ export function SihirbazModal({
|
||||
Sıralamana uygun 24 tercihlik listeni oluştur.
|
||||
</DialogDescription>
|
||||
<SihirbazAdimlarLazy
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
baslangicSecimler={baslangicSecimler}
|
||||
sonButonEtiketi={sonButonEtiketi}
|
||||
|
||||
25
src/app/universite/[slug]/page.tsx
Normal file
25
src/app/universite/[slug]/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getAllUniversiteSlugs } from "@/lib/katalog";
|
||||
import { UniversiteIcerik, uniSayfaMetadata } from "./universite-icerik";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getAllUniversiteSlugs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return uniSayfaMetadata(slug, 1);
|
||||
}
|
||||
|
||||
export default async function UniversitePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <UniversiteIcerik slug={slug} sayfa={1} />;
|
||||
}
|
||||
47
src/app/universite/[slug]/sayfa/[no]/page.tsx
Normal file
47
src/app/universite/[slug]/sayfa/[no]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAllUniversiteler } from "@/lib/katalog";
|
||||
import {
|
||||
UniversiteIcerik,
|
||||
uniSayfaMetadata,
|
||||
uniToplamSayfa,
|
||||
} from "../../universite-icerik";
|
||||
|
||||
// Yalnızca 1'den fazla sayfası olan üniversiteler için 2..N üretilir;
|
||||
// 1. sayfa taban rota (/universite/[slug]) olduğundan burada üretilmez.
|
||||
export function generateStaticParams() {
|
||||
return getAllUniversiteler().flatMap((u) => {
|
||||
const toplam = uniToplamSayfa(u.programSayisi);
|
||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||
slug: u.slug,
|
||||
no: String(i + 2),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function sayfaNo(no: string): number | null {
|
||||
const n = Number(no);
|
||||
return Number.isInteger(n) && n >= 2 ? n : null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug, no } = await params;
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) return {};
|
||||
return uniSayfaMetadata(slug, n);
|
||||
}
|
||||
|
||||
export default async function UniversiteSayfaPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}) {
|
||||
const { slug, no } = await params;
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) notFound();
|
||||
return <UniversiteIcerik slug={slug} sayfa={n} />;
|
||||
}
|
||||
336
src/app/universite/[slug]/universite-icerik.tsx
Normal file
336
src/app/universite/[slug]/universite-icerik.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getUniversiteBySlug, bolumSlugFromIsim } from "@/lib/katalog";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Program } from "@/lib/db";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
const TUR_ETIKET: Record<string, string> = {
|
||||
SAYISAL: "SAY",
|
||||
"EŞİT AĞIRLIK": "EA",
|
||||
SÖZEL: "SÖZ",
|
||||
DİL: "DİL",
|
||||
TYT: "TYT",
|
||||
};
|
||||
|
||||
export function uniToplamSayfa(programSayisi: number): number {
|
||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||
}
|
||||
|
||||
export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||
const uni = getUniversiteBySlug(slug);
|
||||
if (!uni) return {};
|
||||
if (sayfa > 1) {
|
||||
return {
|
||||
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.`,
|
||||
alternates: { canonical: `/universite/${slug}/sayfa/${sayfa}` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: `${uni.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
|
||||
description: `${uni.ad} 2025 yerleştirme verileri: ${uni.programSayisi} programın taban puanları, başarı sıralamaları ve kontenjanları${uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}. 2026 tercih rehberi.`,
|
||||
alternates: { canonical: `/universite/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export function UniversiteIcerik({
|
||||
slug,
|
||||
sayfa,
|
||||
}: {
|
||||
slug: string;
|
||||
sayfa: number;
|
||||
}) {
|
||||
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 toplamSayfa = uniToplamSayfa(tumProgramlar.length);
|
||||
if (sayfa < 1 || sayfa > toplamSayfa) notFound();
|
||||
const dilim = tumProgramlar.slice(
|
||||
(sayfa - 1) * SAYFA_BOYU,
|
||||
sayfa * SAYFA_BOYU,
|
||||
);
|
||||
const gruplar: { fakulte: string; programlar: Program[] }[] = [];
|
||||
for (const p of dilim) {
|
||||
const anahtar = p.fakulte ?? "Diğer";
|
||||
const son = gruplar[gruplar.length - 1];
|
||||
if (son && son.fakulte === anahtar) son.programlar.push(p);
|
||||
else gruplar.push({ fakulte: anahtar, programlar: [p] });
|
||||
}
|
||||
|
||||
const ilkSayfa = sayfa === 1;
|
||||
const buYol = ilkSayfa
|
||||
? `/universite/${slug}`
|
||||
: `/universite/${slug}/sayfa/${sayfa}`;
|
||||
|
||||
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: "Üniversiteler", path: "/universiteler" },
|
||||
{ name: uni.ad, path: `/universite/${slug}` },
|
||||
...(ilkSayfa ? [] : [{ name: `Sayfa ${sayfa}`, path: buYol }]),
|
||||
])}
|
||||
/>
|
||||
{ilkSayfa ? (
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollegeOrUniversity",
|
||||
name: uni.ad,
|
||||
url: `${SITE_URL}/universite/${slug}`,
|
||||
...(uni.il
|
||||
? {
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: trBaslikDuzeni(uni.il),
|
||||
addressCountry: "TR",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
className="flex items-center gap-1 text-xs text-slate-500"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary hover:underline">
|
||||
Ana Sayfa
|
||||
</Link>
|
||||
<ChevronRight className="size-3" aria-hidden />
|
||||
<Link
|
||||
href="/universiteler"
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
Üniversiteler
|
||||
</Link>
|
||||
<ChevronRight className="size-3" aria-hidden />
|
||||
{ilkSayfa ? (
|
||||
<span className="text-slate-700">{uni.ad}</span>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={`/universite/${slug}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
{uni.ad}
|
||||
</Link>
|
||||
<ChevronRight className="size-3" aria-hidden />
|
||||
<span className="text-slate-700">Sayfa {sayfa}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<h1 className="mt-3 font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
{uni.ad} Taban Puanları 2026
|
||||
{ilkSayfa ? "" : ` — Sayfa ${sayfa}`}
|
||||
</h1>
|
||||
|
||||
{ilkSayfa ? (
|
||||
<>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{uni.ad}
|
||||
{uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}, 2025 YKS
|
||||
yerleştirme sonuçlarına göre{" "}
|
||||
<strong>{sayi(uni.programSayisi)} programda</strong> öğrenci aldı
|
||||
{uni.fakulteSayisi > 1
|
||||
? `; programlar ${sayi(uni.fakulteSayisi)} fakülte/yüksekokula dağılıyor`
|
||||
: ""}
|
||||
.
|
||||
{uni.stats.minSira != null ? (
|
||||
<>
|
||||
{" "}
|
||||
En iyi taban başarı sıralaması{" "}
|
||||
<strong>{sayi(uni.stats.minSira)}</strong>.
|
||||
</>
|
||||
) : null}
|
||||
{uni.stats.toplamKontenjan > 0 ? (
|
||||
<> Toplam kontenjan {sayi(uni.stats.toplamKontenjan)}.</>
|
||||
) : null}{" "}
|
||||
Aşağıdaki tablolar 2026 tercihleri için rehber niteliğindedir;{" "}
|
||||
<em>
|
||||
2026 taban puanları ve sıralamaları, yerleştirme sonuçları
|
||||
açıklandığında netleşir.
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
|
||||
<dl className="contents">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Program
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{sayi(uni.programSayisi)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Fakülte / Birim
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{sayi(uni.fakulteSayisi)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Tür
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{uni.unitur === "DEVLET"
|
||||
? "Devlet"
|
||||
: uni.unitur
|
||||
? trBaslikDuzeni(uni.unitur)
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
En iyi sıralama
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-2xl font-bold text-slate-900">
|
||||
{uni.stats.minSira != null ? sayi(uni.stats.minSira) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<UniversiteKonumHaritasi universite={uni.ad} il={uni.il} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{uni.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ı, fakülte sırasıyla). Üniversite özeti ve istatistikler{" "}
|
||||
<Link
|
||||
href={`/universite/${slug}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
ilk sayfada
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PagePixelDivider seed={57} className="mt-8" />
|
||||
|
||||
<div
|
||||
id="programlar"
|
||||
className="mt-8"
|
||||
>
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
Üniversite programları
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Fakülte ve birimlere göre
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{gruplar.map(({ fakulte, programlar }, i) => (
|
||||
<section key={`${fakulte}-${i}`} className="mt-6">
|
||||
<h2 className="mb-3 font-heading text-xl font-bold text-slate-900">
|
||||
{fakulte}
|
||||
</h2>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Puan Türü</TableHead>
|
||||
<TableHead className="text-right">2025 Puan</TableHead>
|
||||
<TableHead className="text-right">2025 Sıralama</TableHead>
|
||||
<TableHead className="text-right">2024 Sıralama</TableHead>
|
||||
<TableHead className="text-right">Kontenjan</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{programlar.map((p) => {
|
||||
const bolumSlug = bolumSlugFromIsim(p.isim);
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="max-w-72">
|
||||
{bolumSlug ? (
|
||||
<Link
|
||||
href={`/bolum/${bolumSlug}`}
|
||||
className="font-medium text-slate-900 hover:text-primary hover:underline"
|
||||
>
|
||||
{p.isim.trim()}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{p.isim.trim()}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{TUR_ETIKET[p.tur] ?? p.tur}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.puan2025 != null
|
||||
? p.puan2025.toFixed(2).replace(".", ",")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.sira2025 != null ? sayi(p.sira2025) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.sira2024 != null ? sayi(p.sira2024) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.kontenjan2025 != null
|
||||
? sayi(p.kontenjan2025)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<p className="mt-3 text-xs leading-5 text-slate-500">
|
||||
"—": o yıl için yerleştirme verisi bulunmuyor (yeni açılan
|
||||
veya o yıl yerleşen çıkmayan program). Kaynak: YÖK Atlas 2025
|
||||
yerleştirme sonuçları.
|
||||
</p>
|
||||
|
||||
<Sayfalama
|
||||
tabanYol={`/universite/${slug}`}
|
||||
sayfa={sayfa}
|
||||
toplamSayfa={toplamSayfa}
|
||||
/>
|
||||
|
||||
<div className="mt-10">
|
||||
<CtaSiraForm
|
||||
baslik={`Sıralamanla ${uni.ad} programlarına yerleşir misin?`}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
src/app/universiteler/page.tsx
Normal file
112
src/app/universiteler/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getAllUniversiteler, type UniOzet } from "@/lib/katalog";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Üniversitelerin Taban Puanları ve Sıralamaları 2026",
|
||||
description:
|
||||
"Devlet ve vakıf üniversitelerinin 2025 yerleştirme taban puanları, başarı sıralamaları ve kontenjanları — üniversite üniversite, 2026 tercihleri için.",
|
||||
alternates: { canonical: "/universiteler" },
|
||||
};
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
|
||||
return (
|
||||
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
{uniler.map((u) => (
|
||||
<li key={u.slug}>
|
||||
<Link
|
||||
href={`/universite/${u.slug}`}
|
||||
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
|
||||
>
|
||||
<span className="group-hover:underline">{u.ad}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{u.il ? `${trBaslikDuzeni(u.il)} · ` : ""}
|
||||
{sayi(u.programSayisi)} program
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UniversitelerPage() {
|
||||
const hepsi = getAllUniversiteler();
|
||||
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
|
||||
const vakif = hepsi.filter(
|
||||
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
|
||||
);
|
||||
const digerUniler = hepsi.filter(
|
||||
(u) => !devlet.includes(u) && !vakif.includes(u),
|
||||
);
|
||||
|
||||
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: "Üniversiteler", path: "/universiteler" },
|
||||
])}
|
||||
/>
|
||||
|
||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
Üniversitelerin Taban Puanları ve Sıralamaları 2026
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
|
||||
Üniversiteye tıklayınca tüm 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 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için
|
||||
rehber niteliğindedir.
|
||||
</p>
|
||||
|
||||
<PagePixelDivider seed={63} className="mt-8" />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Devlet Üniversiteleri
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(devlet.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={devlet} />
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Vakıf Üniversiteleri
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(vakif.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={vakif} />
|
||||
</section>
|
||||
|
||||
{digerUniler.length > 0 ? (
|
||||
<section className="mt-10">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
KKTC ve Yurt Dışı
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(digerUniler.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={digerUniler} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
src/components/bolum-program-listesi.tsx
Normal file
191
src/components/bolum-program-listesi.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Program } from "@/lib/db";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
type SiralamaAlani = "sira2025" | "puan2025" | "kontenjan2025";
|
||||
type SiralamaYonu = "artan" | "azalan";
|
||||
export type BolumProgrami = Program & { universiteSlug: string | null };
|
||||
|
||||
const ALAN_ETIKETLERI: Record<SiralamaAlani, string> = {
|
||||
sira2025: "Sıralama",
|
||||
puan2025: "Puan",
|
||||
kontenjan2025: "Kontenjan",
|
||||
};
|
||||
|
||||
function SiralamaDugmesi({
|
||||
alan,
|
||||
aktifAlan,
|
||||
yon,
|
||||
onSirala,
|
||||
}: {
|
||||
alan: SiralamaAlani;
|
||||
aktifAlan: SiralamaAlani | null;
|
||||
yon: SiralamaYonu;
|
||||
onSirala: (alan: SiralamaAlani) => void;
|
||||
}) {
|
||||
const aktif = alan === aktifAlan;
|
||||
const etiket = ALAN_ETIKETLERI[alan];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSirala(alan)}
|
||||
aria-label={`${etiket}: ${
|
||||
aktif && yon === "artan" ? "azalan" : "artan"
|
||||
} sırala`}
|
||||
aria-pressed={aktif}
|
||||
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span>{etiket}</span>
|
||||
<span className="flex flex-col -space-y-1" aria-hidden>
|
||||
<ChevronUp
|
||||
className={`size-3 ${
|
||||
aktif && yon === "artan" ? "text-primary" : "text-muted-foreground/40"
|
||||
}`}
|
||||
strokeWidth={aktif && yon === "artan" ? 3 : 2}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={`size-3 ${
|
||||
aktif && yon === "azalan" ? "text-primary" : "text-muted-foreground/40"
|
||||
}`}
|
||||
strokeWidth={aktif && yon === "azalan" ? 3 : 2}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function BolumProgramListesi({
|
||||
programlar,
|
||||
bazAd,
|
||||
}: {
|
||||
programlar: BolumProgrami[];
|
||||
bazAd: string;
|
||||
}) {
|
||||
const [aktifAlan, setAktifAlan] = useState<SiralamaAlani | null>(null);
|
||||
const [yon, setYon] = useState<SiralamaYonu>("artan");
|
||||
|
||||
const siraliProgramlar = useMemo(() => {
|
||||
if (!aktifAlan) return programlar;
|
||||
|
||||
return programlar
|
||||
.map((program, index) => ({ program, index }))
|
||||
.sort((a, b) => {
|
||||
const aDeger = a.program[aktifAlan];
|
||||
const bDeger = b.program[aktifAlan];
|
||||
if (aDeger == null && bDeger == null) return a.index - b.index;
|
||||
if (aDeger == null) return 1;
|
||||
if (bDeger == null) return -1;
|
||||
|
||||
const fark = aDeger - bDeger;
|
||||
return fark === 0
|
||||
? a.index - b.index
|
||||
: yon === "artan"
|
||||
? fark
|
||||
: -fark;
|
||||
})
|
||||
.map(({ program }) => program);
|
||||
}, [aktifAlan, programlar, yon]);
|
||||
|
||||
function sirala(alan: SiralamaAlani) {
|
||||
if (aktifAlan === alan) {
|
||||
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
||||
return;
|
||||
}
|
||||
setAktifAlan(alan);
|
||||
setYon("artan");
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-2xl border bg-card">
|
||||
{siraliProgramlar.map((p) => {
|
||||
const uniAd = trBaslikDuzeni(p.universite);
|
||||
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
className="grid gap-4 px-4 py-5 sm:px-5 lg:grid-cols-[minmax(0,1fr)_8rem_8rem_7rem] lg:items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{p.universiteSlug ? (
|
||||
<Link
|
||||
href={`/universite/${p.universiteSlug}`}
|
||||
className="font-semibold text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{uniAd}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-semibold text-foreground">{uniAd}</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
{p.il ? <span>{trBaslikDuzeni(p.il)}</span> : null}
|
||||
{p.il && p.unitur ? <span aria-hidden>·</span> : null}
|
||||
{p.unitur ? (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{p.unitur === "DEVLET"
|
||||
? "Devlet"
|
||||
: trBaslikDuzeni(p.unitur)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{varyant ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{varyant}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-3 gap-3 lg:contents">
|
||||
<div className="lg:text-right">
|
||||
<dt>
|
||||
<SiralamaDugmesi
|
||||
alan="sira2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</dt>
|
||||
<dd className="mt-1 font-semibold tabular-nums text-foreground">
|
||||
{p.sira2025 != null ? sayi(p.sira2025) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="lg:text-right">
|
||||
<dt>
|
||||
<SiralamaDugmesi
|
||||
alan="puan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium tabular-nums text-foreground">
|
||||
{p.puan2025 != null
|
||||
? p.puan2025.toFixed(2).replace(".", ",")
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="lg:text-right">
|
||||
<dt>
|
||||
<SiralamaDugmesi
|
||||
alan="kontenjan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium tabular-nums text-foreground">
|
||||
{p.kontenjan2025 != null ? sayi(p.kontenjan2025) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
57
src/components/cta-sira-form.tsx
Normal file
57
src/components/cta-sira-form.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Programatik SEO sayfalarındaki funnel girişi: JS'siz düz GET formu,
|
||||
* /sonuc?sira=&tur= akışına düşer. Client bileşen DEĞİL — sayfalar tamamen
|
||||
* statik prerender edilir, form tarayıcının kendi submit'iyle çalışır.
|
||||
*/
|
||||
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."}
|
||||
</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.
|
||||
</p>
|
||||
<form
|
||||
action="/sonuc"
|
||||
method="get"
|
||||
className="mt-4 flex flex-col gap-3 sm:flex-row"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="sira"
|
||||
required
|
||||
min={1}
|
||||
max={4000000}
|
||||
inputMode="numeric"
|
||||
placeholder="Başarı sıralaman (ör. 85000)"
|
||||
aria-label="YKS başarı sıralaması"
|
||||
className="h-11 flex-1 rounded-lg border border-slate-300 bg-white px-4 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
name="tur"
|
||||
aria-label="Puan türü"
|
||||
defaultValue="say"
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="say">Sayısal</option>
|
||||
<option value="ea">Eşit Ağırlık</option>
|
||||
<option value="soz">Sözel</option>
|
||||
<option value="dil">Dil</option>
|
||||
<option value="tyt">TYT (2 yıllık)</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-6 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Programları gör
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -170,9 +170,11 @@ export function HeroForm() {
|
||||
<DialogDescription className="sr-only">
|
||||
Sıralamana uygun seçimlerini yap, tercih planına geç.
|
||||
</DialogDescription>
|
||||
{facetler ? (
|
||||
{facetler && sira ? (
|
||||
<SihirbazAdimlarLazy
|
||||
key={`${sira}-${tur}`}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
sonButonEtiketi={
|
||||
girisli ? "Listemi oluştur (3 kredi)" : "Ücretsiz tablonu gör"
|
||||
|
||||
398
src/components/katalog-arama.tsx
Normal file
398
src/components/katalog-arama.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpenText,
|
||||
Building2,
|
||||
GraduationCap,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type BolumSonucu = {
|
||||
href: string;
|
||||
ad: string;
|
||||
programSayisi: number;
|
||||
enIyiSira: number | null;
|
||||
seviye: string;
|
||||
};
|
||||
|
||||
type UniversiteSonucu = {
|
||||
href: string;
|
||||
ad: string;
|
||||
il: string | null;
|
||||
tur: string | null;
|
||||
programSayisi: number;
|
||||
fakulteSayisi: number;
|
||||
};
|
||||
|
||||
type AramaSonuclari = {
|
||||
bolumler: BolumSonucu[];
|
||||
universiteler: UniversiteSonucu[];
|
||||
};
|
||||
|
||||
const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
|
||||
const sayi = (deger: number) => deger.toLocaleString("tr-TR");
|
||||
|
||||
export function KatalogArama() {
|
||||
const [acik, setAcik] = useState(false);
|
||||
const [sorgu, setSorgu] = useState("");
|
||||
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const temizSorgu = sorgu.trim();
|
||||
const aramaAktif = temizSorgu.length >= 2;
|
||||
const sonucVar =
|
||||
sonuclar.bolumler.length > 0 || sonuclar.universiteler.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!acik || !aramaAktif) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const zamanlayici = window.setTimeout(async () => {
|
||||
setYukleniyor(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) throw new Error("Arama başarısız");
|
||||
setSonuclar((await response.json()) as AramaSonuclari);
|
||||
} catch (hata) {
|
||||
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setYukleniyor(false);
|
||||
}
|
||||
}, 160);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(zamanlayici);
|
||||
controller.abort();
|
||||
};
|
||||
}, [acik, aramaAktif, temizSorgu]);
|
||||
|
||||
function modalDegisti(yeniAcik: boolean) {
|
||||
setAcik(yeniAcik);
|
||||
if (!yeniAcik) {
|
||||
setSorgu("");
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
}
|
||||
}
|
||||
|
||||
function kapat() {
|
||||
setAcik(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 sm:flex">
|
||||
<Link
|
||||
href="/#nasil-calisir"
|
||||
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
Nasıl çalışır?
|
||||
</Link>
|
||||
<Link
|
||||
href="/#karsilastirma"
|
||||
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
Karşılaştır
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAcik(true)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full bg-slate-900 px-5 py-2 text-white transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
|
||||
>
|
||||
<Search className="size-4" aria-hidden />
|
||||
Ara
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAcik(true)}
|
||||
aria-label="Bölüm veya üniversite ara"
|
||||
className="ml-auto flex size-10 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:hidden"
|
||||
>
|
||||
<Search className="size-4" aria-hidden />
|
||||
</button>
|
||||
|
||||
<Dialog open={acik} onOpenChange={modalDegisti}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="max-h-[min(720px,calc(100dvh-2rem))] gap-0 overflow-hidden rounded-2xl bg-white p-0 duration-200 sm:max-w-2xl motion-reduce:data-open:zoom-in-100 motion-reduce:data-closed:zoom-out-100"
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Bölüm ve üniversite ara
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
YKS bölümlerini ve üniversiteleri tek yerden ara.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex items-center border-b border-slate-200 px-4 sm:px-5">
|
||||
<Search
|
||||
className="mr-3 size-5 shrink-0 text-slate-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={sorgu}
|
||||
onChange={(event) => {
|
||||
const yeniSorgu = event.target.value;
|
||||
setSorgu(yeniSorgu);
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
setYukleniyor(yeniSorgu.trim().length >= 2);
|
||||
}}
|
||||
placeholder="Bölüm veya üniversite ara..."
|
||||
aria-label="Bölüm veya üniversite ara"
|
||||
autoComplete="off"
|
||||
className="h-16 min-w-0 flex-1 bg-transparent text-base text-slate-950 outline-none placeholder:text-slate-400 sm:h-18 sm:text-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={kapat}
|
||||
aria-label="Aramayı kapat"
|
||||
className="flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-72 overflow-y-auto overscroll-contain p-3 sm:p-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
{!aramaAktif ? (
|
||||
<BaslangicIcerigi onSec={kapat} />
|
||||
) : yukleniyor ? (
|
||||
<AramaIskeleti />
|
||||
) : sonucVar ? (
|
||||
<div className="space-y-5">
|
||||
{sonuclar.bolumler.length > 0 ? (
|
||||
<SonucGrubu
|
||||
baslik="Bölümler"
|
||||
ikon={<GraduationCap className="size-4" aria-hidden />}
|
||||
>
|
||||
{sonuclar.bolumler.map((bolum) => (
|
||||
<SonucLinki
|
||||
key={bolum.href}
|
||||
href={bolum.href}
|
||||
baslik={bolum.ad}
|
||||
meta={`${bolum.seviye} · ${sayi(bolum.programSayisi)} program`}
|
||||
detay={
|
||||
bolum.enIyiSira
|
||||
? `En iyi sıra ${sayi(bolum.enIyiSira)}`
|
||||
: "Sıralama verisi yok"
|
||||
}
|
||||
onSec={kapat}
|
||||
/>
|
||||
))}
|
||||
</SonucGrubu>
|
||||
) : null}
|
||||
|
||||
{sonuclar.universiteler.length > 0 ? (
|
||||
<SonucGrubu
|
||||
baslik="Üniversiteler"
|
||||
ikon={<Building2 className="size-4" aria-hidden />}
|
||||
>
|
||||
{sonuclar.universiteler.map((universite) => (
|
||||
<SonucLinki
|
||||
key={universite.href}
|
||||
href={universite.href}
|
||||
baslik={universite.ad}
|
||||
meta={
|
||||
[universite.il, universite.tur]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Üniversite"
|
||||
}
|
||||
detay={`${sayi(universite.programSayisi)} program · ${sayi(universite.fakulteSayisi)} fakülte`}
|
||||
onSec={kapat}
|
||||
/>
|
||||
))}
|
||||
</SonucGrubu>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-64 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-500">
|
||||
<Search className="size-5" aria-hidden />
|
||||
</div>
|
||||
<p className="mt-4 font-medium text-slate-900">
|
||||
"{temizSorgu}" için sonuç bulunamadı
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm leading-6 text-slate-500">
|
||||
Bölüm veya üniversite adını farklı yazarak tekrar dene.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-200 bg-slate-50/80 px-4 py-3 text-xs text-slate-500 sm:px-5">
|
||||
<span>En az 2 harf yazarak ara</span>
|
||||
<span className="hidden sm:inline">Esc ile kapat</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BaslangicIcerigi({ onSec }: { onSec: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="px-2 pb-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||
Keşfet
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<KesfetLinki
|
||||
href="/bolumler"
|
||||
ikon={<GraduationCap className="size-5" aria-hidden />}
|
||||
baslik="Tüm bölümler"
|
||||
aciklama="Lisans ve önlisans"
|
||||
onSec={onSec}
|
||||
/>
|
||||
<KesfetLinki
|
||||
href="/universiteler"
|
||||
ikon={<Building2 className="size-5" aria-hidden />}
|
||||
baslik="Üniversiteler"
|
||||
aciklama="Devlet ve vakıf"
|
||||
onSec={onSec}
|
||||
/>
|
||||
<KesfetLinki
|
||||
href="/rehber"
|
||||
ikon={<BookOpenText className="size-5" aria-hidden />}
|
||||
baslik="Tercih rehberi"
|
||||
aciklama="Sorularına yanıtlar"
|
||||
onSec={onSec}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 rounded-xl border border-orange-100 bg-orange-50/70 p-4">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
Neyi merak ediyorsun?
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
“Psikoloji” veya “Boğaziçi” gibi bir ad yaz; eşleşen sayfaları ve
|
||||
temel bilgilerini burada önizle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KesfetLinki({
|
||||
href,
|
||||
ikon,
|
||||
baslik,
|
||||
aciklama,
|
||||
onSec,
|
||||
}: {
|
||||
href: string;
|
||||
ikon: React.ReactNode;
|
||||
baslik: string;
|
||||
aciklama: string;
|
||||
onSec: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onSec}
|
||||
className="group rounded-xl border border-slate-200 bg-white p-4 transition-[border-color,background-color,transform] duration-150 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.98]"
|
||||
>
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-slate-100 text-slate-600 transition-colors group-hover:bg-white">
|
||||
{ikon}
|
||||
</span>
|
||||
<span className="mt-3 block text-sm font-semibold text-slate-900">
|
||||
{baslik}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">{aciklama}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SonucGrubu({
|
||||
baslik,
|
||||
ikon,
|
||||
children,
|
||||
}: {
|
||||
baslik: string;
|
||||
ikon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||
{ikon}
|
||||
{baslik}
|
||||
</h3>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SonucLinki({
|
||||
href,
|
||||
baslik,
|
||||
meta,
|
||||
detay,
|
||||
onSec,
|
||||
}: {
|
||||
href: string;
|
||||
baslik: string;
|
||||
meta: string;
|
||||
detay: string;
|
||||
onSec: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onSec}
|
||||
className="group flex items-center gap-3 rounded-xl px-3 py-3 transition-colors duration-150 hover:bg-slate-100"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-slate-900">
|
||||
{baslik}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-slate-500">
|
||||
{meta}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-right text-xs text-slate-400 sm:block">
|
||||
{detay}
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="size-4 shrink-0 text-slate-300 transition-[color,transform] duration-150 group-hover:translate-x-0.5 group-hover:text-slate-600"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AramaIskeleti() {
|
||||
return (
|
||||
<div className="space-y-2 px-2 pt-2" aria-label="Aranıyor">
|
||||
{[0, 1, 2, 3].map((sira) => (
|
||||
<div
|
||||
key={sira}
|
||||
className="flex h-16 animate-pulse items-center rounded-xl bg-slate-100 px-3 motion-reduce:animate-none"
|
||||
>
|
||||
<div className="h-4 w-2/5 rounded bg-slate-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
// Sonuç sayfasının AI'sız ham listesi: yalnızca başarı sıralamasıyla
|
||||
// yokatlas'tan gelen programlar, dilim sekmeleri halinde. Her satırın "+"
|
||||
// yokatlas'tan gelen programlar, dilim sekmeleri halinde ve yakından uzağa
|
||||
// sayfalanır ("daha fazla göster" /api/programlar'dan akıtır). Her satırın "+"
|
||||
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -9,6 +10,7 @@ import Link from "next/link";
|
||||
import { useLinkStatus } from "next/link";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Rocket,
|
||||
Scale,
|
||||
@@ -16,8 +18,9 @@ import {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { Program, PuanTuruKey, RankResults } from "@/lib/db";
|
||||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { turkishSlugify } from "@/lib/slug";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -33,9 +36,6 @@ import {
|
||||
useManuelListe,
|
||||
type ManuelTercih,
|
||||
} from "@/components/manuel-liste/store";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
|
||||
type DilimKey = keyof RankResults;
|
||||
|
||||
const DILIMLER: {
|
||||
key: DilimKey;
|
||||
@@ -144,6 +144,20 @@ function tercihYap(p: Program, adaySira: number): ManuelTercih {
|
||||
};
|
||||
}
|
||||
|
||||
const BOS_EKSTRA: Record<DilimKey, Program[]> = {
|
||||
hayal: [],
|
||||
dengeli: [],
|
||||
garanti: [],
|
||||
};
|
||||
|
||||
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
|
||||
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
|
||||
function varsayilanDilim(sonuclar: RankResults): DilimKey {
|
||||
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
|
||||
? "garanti"
|
||||
: "dengeli";
|
||||
}
|
||||
|
||||
export function ProgramTablosu({
|
||||
sonuclar,
|
||||
adaySira,
|
||||
@@ -154,14 +168,59 @@ export function ProgramTablosu({
|
||||
tur: PuanTuruKey;
|
||||
}) {
|
||||
const liste = useManuelListe();
|
||||
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
|
||||
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
|
||||
varsayilanDilim(sonuclar),
|
||||
);
|
||||
const listedekiler = new Set(liste.map((t) => t.id));
|
||||
const doldu = liste.length >= MANUEL_LISTE_MAX;
|
||||
|
||||
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür değişince
|
||||
// (puan türü seçici aynı bileşeni yeniden render eder, remount etmez)
|
||||
// render sırasında sıfırlanır ki eski türün satırları yenisine karışmasın.
|
||||
const [ekstra, setEkstra] = useState(BOS_EKSTRA);
|
||||
const [yuklenen, setYuklenen] = useState<DilimKey | null>(null);
|
||||
const veriAnahtari = `${adaySira}-${tur}`;
|
||||
const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari);
|
||||
if (veriAnahtari !== oncekiAnahtar) {
|
||||
setOncekiAnahtar(veriAnahtari);
|
||||
setEkstra(BOS_EKSTRA);
|
||||
// Yeni sıra/türde dengeli+hayal boş kaldıysa boş sekmede bırakma
|
||||
if (varsayilanDilim(sonuclar) === "garanti" && aktifDilim !== "garanti") {
|
||||
setAktifDilim("garanti");
|
||||
}
|
||||
}
|
||||
|
||||
function eklemeyiDene(p: Program) {
|
||||
ekle(tercihYap(p, adaySira));
|
||||
}
|
||||
|
||||
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
|
||||
setYuklenen(dilim);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const veri = (await res.json()) as { programlar: Program[] };
|
||||
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
|
||||
setEkstra((mevcut) => {
|
||||
const eldekiler = new Set(
|
||||
[...sonuclar[dilim], ...mevcut[dilim]].map((p) => p.id),
|
||||
);
|
||||
return {
|
||||
...mevcut,
|
||||
[dilim]: [
|
||||
...mevcut[dilim],
|
||||
...veri.programlar.filter((p) => !eldekiler.has(p.id)),
|
||||
],
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
} finally {
|
||||
setYuklenen(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
|
||||
<div>
|
||||
@@ -177,11 +236,6 @@ export function ProgramTablosu({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
|
||||
<div className="mt-5">
|
||||
<ManuelHarita />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={aktifDilim}
|
||||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||||
@@ -197,7 +251,7 @@ export function ProgramTablosu({
|
||||
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
|
||||
{d.etiket}
|
||||
<span className="text-xs tabular-nums text-slate-400">
|
||||
{sonuclar[d.key].length}
|
||||
{sonuclar.toplam[d.key].toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
@@ -211,49 +265,70 @@ export function ProgramTablosu({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{DILIMLER.map((d) => (
|
||||
<TabsContent key={d.key} value={d.key}>
|
||||
<p className="mt-2 text-xs text-slate-500">{d.aciklama}</p>
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
{sonuclar[d.key].length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
Bu dilimde filtrene uyan program bulunamadı.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right sm:table-cell">
|
||||
Kontenjan
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Taban sıra</TableHead>
|
||||
<TableHead className="w-16 text-right">
|
||||
<span className="sr-only">Listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sonuclar[d.key].map((p) => (
|
||||
<ProgramSatiri
|
||||
key={p.id}
|
||||
program={p}
|
||||
adaySira={adaySira}
|
||||
listede={listedekiler.has(p.id)}
|
||||
doldu={doldu}
|
||||
onEkle={eklemeyiDene}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
{DILIMLER.map((d) => {
|
||||
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]];
|
||||
const toplam = sonuclar.toplam[d.key];
|
||||
return (
|
||||
<TabsContent key={d.key} value={d.key}>
|
||||
<p className="mt-2 text-xs text-slate-500">{d.aciklama}</p>
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
{satirlar.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
Bu dilimde filtrene uyan program bulunamadı.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right sm:table-cell">
|
||||
Kontenjan
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Taban sıra</TableHead>
|
||||
<TableHead className="w-16 text-right">
|
||||
<span className="sr-only">Listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{satirlar.map((p) => (
|
||||
<ProgramSatiri
|
||||
key={p.id}
|
||||
program={p}
|
||||
adaySira={adaySira}
|
||||
listede={listedekiler.has(p.id)}
|
||||
doldu={doldu}
|
||||
onEkle={eklemeyiDene}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
{satirlar.length < toplam ? (
|
||||
<div className="mt-3 flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={yuklenen === d.key}
|
||||
onClick={() => dahaFazlaYukle(d.key, satirlar.length)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors duration-150 hover:border-slate-300 hover:bg-slate-50 disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
{yuklenen === d.key ? "Yükleniyor…" : "Daha fazla göster"}
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-slate-400">
|
||||
{satirlar.length.toLocaleString("tr-TR")} /{" "}
|
||||
{toplam.toLocaleString("tr-TR")} program
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
@@ -339,6 +414,14 @@ function TabanTrendi({ p }: { p: Program }) {
|
||||
);
|
||||
}
|
||||
|
||||
// /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
|
||||
// kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek
|
||||
// için burada tekrarlanır. Katalog haritası DB'deki tüm universite
|
||||
// değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
|
||||
function uniSayfaSlug(ad: string): string {
|
||||
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
|
||||
}
|
||||
|
||||
function ProgramSatiri({
|
||||
program: p,
|
||||
adaySira,
|
||||
@@ -379,7 +462,15 @@ function ProgramSatiri({
|
||||
{devlet ? "Devlet" : "Vakıf"}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{p.universite}
|
||||
<Link
|
||||
href={`/universite/${uniSayfaSlug(p.universite)}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
title={`${p.universite} taban puanları sayfası`}
|
||||
// Satırdaki "+" ekleme akışıyla karışmasın: tıklama satıra yayılmaz
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{p.universite}
|
||||
</Link>
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
{p.sure ? (
|
||||
|
||||
159
src/components/rehber-kapak.tsx
Normal file
159
src/components/rehber-kapak.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
type RehberKapakProps = {
|
||||
baslik: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const VURGU_DESENLERI = [
|
||||
/^YKS Tercih Listesi/i,
|
||||
/^Ölü Tercih/i,
|
||||
/^Kaç Sıralama/i,
|
||||
/^Ek Yerleştirme/i,
|
||||
/^Taban Puan/i,
|
||||
/^YKS 2026 Tercih Takvimi/i,
|
||||
/^Devlet mi Vakıf mı\?/i,
|
||||
/^Baraj ve Başarı Sıralaması Şartları/i,
|
||||
/^TYT ile Önlisans Tercihi/i,
|
||||
/^Taban Sıralamalar/i,
|
||||
];
|
||||
|
||||
export function rehberBasliginiBol(baslik: string) {
|
||||
const eslesme = VURGU_DESENLERI.map((desen) => baslik.match(desen)).find(
|
||||
Boolean,
|
||||
);
|
||||
const vurgu = eslesme?.[0] ?? baslik.split(/\s+/).slice(0, 3).join(" ");
|
||||
return { vurgu, kalan: baslik.slice(vurgu.length) };
|
||||
}
|
||||
|
||||
export function rehberMetniniParcala(metin: string) {
|
||||
return metin.split(/(\d+(?:[.,]\d+)*)/g).filter(Boolean);
|
||||
}
|
||||
|
||||
function KapakMetni({ metin }: { metin: string }) {
|
||||
return rehberMetniniParcala(metin).map((parca, i) =>
|
||||
/^\d/.test(parca) ? (
|
||||
<span key={i} className="font-bricolage font-black italic">
|
||||
{parca}
|
||||
</span>
|
||||
) : (
|
||||
parca
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const KAPAK_PIKSELLERI = [
|
||||
[960, 50, 0.16], [980, 50, 0.28], [1020, 50, 0.12],
|
||||
[940, 70, 0.24], [980, 70, 0.12], [1000, 70, 0.3], [1060, 70, 0.18],
|
||||
[960, 90, 0.12], [1000, 90, 0.2], [1040, 90, 0.1], [1080, 90, 0.26],
|
||||
[1080, 110, 0.14], [1120, 110, 0.22], [1140, 110, 0.12],
|
||||
[60, 490, 0.12], [100, 490, 0.25], [120, 490, 0.14],
|
||||
[40, 510, 0.24], [80, 510, 0.12], [120, 510, 0.3], [160, 510, 0.16],
|
||||
[60, 530, 0.14], [100, 530, 0.22], [140, 530, 0.1], [180, 530, 0.27],
|
||||
] as const;
|
||||
|
||||
export function RehberKapak({
|
||||
baslik,
|
||||
className = "",
|
||||
compact = false,
|
||||
}: RehberKapakProps) {
|
||||
const { vurgu, kalan } = rehberBasliginiBol(baslik);
|
||||
const baslikBoyutu = compact
|
||||
? baslik.length > 70
|
||||
? "text-[clamp(15px,1.8vw,20px)]"
|
||||
: baslik.length > 52
|
||||
? "text-[clamp(17px,2.1vw,23px)]"
|
||||
: "text-[clamp(19px,2.4vw,27px)]"
|
||||
: baslik.length > 70
|
||||
? "text-[clamp(28px,4vw,48px)]"
|
||||
: baslik.length > 52
|
||||
? "text-[clamp(32px,4.5vw,54px)]"
|
||||
: "text-[clamp(36px,5vw,62px)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative isolate aspect-1200/630 w-full overflow-hidden rounded-3xl border border-[#2563eb]/15 bg-white font-bricolage text-[#2563eb] ${className}`}
|
||||
role="img"
|
||||
aria-label={`${baslik} yazı kapağı`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1200 630"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 size-full text-[#2563eb]"
|
||||
aria-hidden
|
||||
>
|
||||
{KAPAK_PIKSELLERI.map(([x, y, opacity]) => (
|
||||
<rect
|
||||
key={`${x}-${y}`}
|
||||
x={x}
|
||||
y={y}
|
||||
width="14"
|
||||
height="14"
|
||||
rx="4"
|
||||
fill="currentColor"
|
||||
fillOpacity={opacity}
|
||||
/>
|
||||
))}
|
||||
<rect x="1040" y="50" width="14" height="14" rx="4" fill="#ff5a00" fillOpacity="0.8" />
|
||||
<rect x="80" y="530" width="14" height="14" rx="4" fill="#ff5a00" fillOpacity="0.8" />
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className={`relative flex h-full flex-col justify-between ${
|
||||
compact ? "p-[clamp(14px,2.2vw,24px)]" : "p-[clamp(20px,5vw,60px)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`font-sans font-bold uppercase tracking-[0.2em] text-[#2563eb] ${
|
||||
compact
|
||||
? "text-[clamp(8px,1vw,11px)]"
|
||||
: "text-[clamp(10px,1.5vw,18px)]"
|
||||
}`}
|
||||
>
|
||||
Tercih Rehberi
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className={`max-w-[88%] text-balance font-heading font-light leading-[1.02] tracking-tight text-[#2563eb] ${baslikBoyutu}`}
|
||||
>
|
||||
<span className="font-bricolage font-semibold tracking-tighter text-[#ff5a00]">
|
||||
<KapakMetni metin={vurgu} />
|
||||
</span>
|
||||
{kalan ? (
|
||||
<>
|
||||
{/^\s/.test(kalan) ? " " : null}
|
||||
<KapakMetni metin={kalan.trimStart()} />
|
||||
</>
|
||||
) : null}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<svg
|
||||
viewBox="0 0 180 180"
|
||||
className={
|
||||
compact
|
||||
? "size-[clamp(16px,1.8vw,22px)] shrink-0"
|
||||
: "size-[clamp(22px,3vw,38px)] shrink-0"
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="27" y="56" width="57" height="57" rx="13" fill="#3b82f6" />
|
||||
<rect x="91" y="29" width="56" height="56" rx="13" fill="#fdc7a7" />
|
||||
<rect x="91" y="95" width="56" height="56" rx="13" fill="#ff5a00" />
|
||||
</svg>
|
||||
<span
|
||||
className={`tracking-tighter text-[#2563eb] ${
|
||||
compact
|
||||
? "text-[clamp(10px,1.2vw,14px)]"
|
||||
: "text-[clamp(13px,2vw,24px)]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-light">Kolay</span>
|
||||
<span className="font-semibold">Tercih</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/sayfalama.tsx
Normal file
98
src/components/sayfalama.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
/** Programatik tablo sayfaları için satır dilimi boyutu. */
|
||||
export const SAYFA_BOYU = 50;
|
||||
|
||||
/**
|
||||
* Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in
|
||||
* kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek
|
||||
* <Link>, Google her sayfayı ayrı statik URL olarak tarar.
|
||||
*/
|
||||
export function Sayfalama({
|
||||
tabanYol,
|
||||
sayfa,
|
||||
toplamSayfa,
|
||||
sorgu,
|
||||
}: {
|
||||
tabanYol: string;
|
||||
sayfa: number;
|
||||
toplamSayfa: number;
|
||||
sorgu?: string;
|
||||
}) {
|
||||
if (toplamSayfa <= 1) return null;
|
||||
const yol = (n: number) => {
|
||||
const path = n === 1 ? tabanYol : `${tabanYol}/sayfa/${n}`;
|
||||
return sorgu ? `${path}?${sorgu}` : path;
|
||||
};
|
||||
|
||||
// 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son
|
||||
const numaralar: (number | "...")[] = [];
|
||||
if (toplamSayfa <= 7) {
|
||||
for (let n = 1; n <= toplamSayfa; n++) numaralar.push(n);
|
||||
} else {
|
||||
numaralar.push(1);
|
||||
if (sayfa > 3) numaralar.push("...");
|
||||
for (
|
||||
let n = Math.max(2, sayfa - 1);
|
||||
n <= Math.min(toplamSayfa - 1, sayfa + 1);
|
||||
n++
|
||||
) {
|
||||
numaralar.push(n);
|
||||
}
|
||||
if (sayfa < toplamSayfa - 2) numaralar.push("...");
|
||||
numaralar.push(toplamSayfa);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sayfalama"
|
||||
className="mt-6 flex flex-wrap items-center justify-center gap-1.5"
|
||||
>
|
||||
{sayfa > 1 ? (
|
||||
<Link
|
||||
href={yol(sayfa - 1)}
|
||||
rel="prev"
|
||||
className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden />
|
||||
Önceki
|
||||
</Link>
|
||||
) : null}
|
||||
{numaralar.map((n, i) =>
|
||||
n === "..." ? (
|
||||
<span
|
||||
key={`bosluk-${i}`}
|
||||
className="px-1.5 text-sm text-slate-400"
|
||||
aria-hidden
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={n}
|
||||
href={yol(n)}
|
||||
aria-current={n === sayfa ? "page" : undefined}
|
||||
className={
|
||||
n === sayfa
|
||||
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
|
||||
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
|
||||
}
|
||||
>
|
||||
{n}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
{sayfa < toplamSayfa ? (
|
||||
<Link
|
||||
href={yol(sayfa + 1)}
|
||||
rel="next"
|
||||
className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
Sonraki
|
||||
<ChevronRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
// Sihirbazın 3 adımlık seçim gövdesi. Hem ana sayfadaki hero modalında hem de
|
||||
// /sonuc'taki sihirbaz modalında kullanılır; seçim state'ini kendi tutar,
|
||||
// tamamlanınca onTamamla(secimler) çağırır.
|
||||
//
|
||||
// Adımlar kademeli süzülür: 2. adımın illeri seçili kategorilere, 3. adımın
|
||||
// üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import useMeasure from "react-use-measure";
|
||||
@@ -51,11 +54,15 @@ export function Cip({
|
||||
const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name)));
|
||||
|
||||
export function SihirbazAdimlar({
|
||||
sira,
|
||||
tur,
|
||||
facetler,
|
||||
baslangicSecimler,
|
||||
sonButonEtiketi,
|
||||
onTamamla,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
facetler: SihirbazFacetleri;
|
||||
baslangicSecimler?: SihirbazSecimleri | null;
|
||||
sonButonEtiketi: string;
|
||||
@@ -77,6 +84,45 @@ export function SihirbazAdimlar({
|
||||
baslangicSecimler?.oncelikler ?? [],
|
||||
);
|
||||
|
||||
// Kademeli facet'ler: kategoriler daima ilk yükten gelir; il ve üniversite
|
||||
// tipi sayıları adıma girerken o anki seçimlerle tazelenir. Fetch başarısız
|
||||
// olursa eldeki (daha geniş) liste kalır — sihirbaz asla kilitlenmez.
|
||||
const [canliFacetler, setCanliFacetler] = useState(facetler);
|
||||
const [facetYukleniyor, setFacetYukleniyor] = useState(false);
|
||||
useEffect(() => {
|
||||
if (adim === 0) return;
|
||||
const ctrl = new AbortController();
|
||||
const params = new URLSearchParams({ sira: String(sira), tur });
|
||||
for (const k of kategoriler) params.append("kategori", k);
|
||||
if (adim === 2) for (const i of iller) params.append("il", i);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||||
setFacetYukleniyor(true);
|
||||
fetch(`/api/facetler?${params}`, { signal: ctrl.signal })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((veri: { facetler: SihirbazFacetleri } | null) => {
|
||||
if (!veri) return;
|
||||
setCanliFacetler((mevcut) => ({
|
||||
...mevcut,
|
||||
iller: veri.facetler.iller,
|
||||
uniturler: veri.facetler.uniturler,
|
||||
}));
|
||||
// Seçili tip daralan filtrede yoksa (ör. seçilen ilde vakıf kalmadı)
|
||||
// görünmez bir seçim bırakma
|
||||
setUniversiteTipi((tip) =>
|
||||
tip !== "farketmez" &&
|
||||
!veri.facetler.uniturler.some((u) => u.grup === tip)
|
||||
? "farketmez"
|
||||
: tip,
|
||||
);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setFacetYukleniyor(false));
|
||||
return () => ctrl.abort();
|
||||
// Seçimler yalnızca önceki adımlarda değişebildiği için adım geçişinde
|
||||
// okumak yeterli; her çip tıklamasında istek atmayalım.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adim]);
|
||||
|
||||
function listeDegistir(
|
||||
liste: string[],
|
||||
setListe: (v: string[]) => void,
|
||||
@@ -198,10 +244,14 @@ export function SihirbazAdimlar({
|
||||
</p>
|
||||
|
||||
{/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde
|
||||
güvenilir dokunma hedefi değil); tüm iller program sayısına
|
||||
göre sıralı listelenir. */}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{[...facetler.iller]
|
||||
güvenilir dokunma hedefi değil); iller seçili kategorilere göre
|
||||
süzülmüş, program sayısına göre sıralı listelenir. */}
|
||||
<div
|
||||
className={`mt-4 flex flex-wrap gap-2 ${
|
||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{[...canliFacetler.iller]
|
||||
.sort((a, b) => b.adet - a.adet)
|
||||
.map((i) => (
|
||||
<Cip
|
||||
@@ -237,7 +287,7 @@ export function SihirbazAdimlar({
|
||||
{/* Harita geniş ekranda görsel destek olarak kalır */}
|
||||
<div className="mt-4 hidden sm:block">
|
||||
<IlSecimHaritasi
|
||||
iller={facetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
|
||||
iller={canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
|
||||
secili={iller}
|
||||
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
|
||||
/>
|
||||
@@ -256,8 +306,12 @@ export function SihirbazAdimlar({
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
|
||||
Üniversite tipi
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{facetler.uniturler.map((u) => (
|
||||
<div
|
||||
className={`flex flex-wrap gap-2 ${
|
||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{canliFacetler.uniturler.map((u) => (
|
||||
<Cip
|
||||
key={u.grup}
|
||||
secili={universiteTipi === u.grup}
|
||||
|
||||
161
src/components/site-footer.tsx
Normal file
161
src/components/site-footer.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import Link from "next/link";
|
||||
import { getAllBolumler } from "@/lib/katalog";
|
||||
|
||||
/**
|
||||
* Site footer'ı: yasal metin + SEO iç link kolonları. Server component —
|
||||
* popüler bölümler listesi build sırasında katalogdan gelir. Layout'a değil
|
||||
* sayfalara eklenir; auth/utility ekranlarında (giris, listem...) footer yok.
|
||||
*/
|
||||
export function SiteFooter() {
|
||||
const populerBolumler = [...getAllBolumler()]
|
||||
.sort((a, b) => b.programSayisi - a.programSayisi)
|
||||
.slice(0, 8);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-slate-200/70 pb-32 pt-12">
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<nav aria-label="Popüler bölümler">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Popüler Bölümler
|
||||
</h2>
|
||||
<ul className="mt-3 grid grid-cols-1 gap-1.5">
|
||||
{populerBolumler.map((b) => (
|
||||
<li key={b.slug}>
|
||||
<Link
|
||||
href={`/bolum/${b.slug}`}
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
{b.ad} Taban Puanları
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav aria-label="Keşfet">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Keşfet
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
<li>
|
||||
<Link
|
||||
href="/#hero-form"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
YKS Tercih Robotu 2026
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/bolumler"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Bölüm Taban Puanları
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/universiteler"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Üniversite Taban Puanları
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/rehber"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Tercih Rehberi
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/meraklisina"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Meraklısına: Nasıl çalışır?
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav aria-label="Rehber yazıları">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Rehberden
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
<li>
|
||||
<Link
|
||||
href="/rehber/tercih-listesi-nasil-yapilir"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Tercih Listesi Nasıl Yapılır?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/rehber/kac-siralama-ile-hangi-bolume-girebilirim"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Kaç Sıralama ile Hangi Bölüme Girebilirim?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/rehber/olu-tercih-nedir"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Ölü Tercih Nedir?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/rehber/ek-yerlestirme-2026"
|
||||
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Ek Yerleştirme Rehberi
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-t border-slate-200/70 pt-6 text-center text-sm leading-relaxed text-slate-500">
|
||||
<p>
|
||||
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
|
||||
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
|
||||
Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
|
||||
</p>
|
||||
<nav
|
||||
aria-label="Yasal sayfalar"
|
||||
className="mt-4 flex flex-wrap items-center justify-center gap-4"
|
||||
>
|
||||
<Link
|
||||
href="/gizlilik"
|
||||
className="hover:text-slate-700 hover:underline"
|
||||
>
|
||||
Gizlilik & KVKK
|
||||
</Link>
|
||||
<Link
|
||||
href="/kosullar"
|
||||
className="hover:text-slate-700 hover:underline"
|
||||
>
|
||||
Kullanım & iade koşulları
|
||||
</Link>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
className="hover:text-slate-700 hover:underline"
|
||||
>
|
||||
İletişim
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Suspense } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { KatalogArama } from "@/components/katalog-arama";
|
||||
import { UserNav, UserNavFallback } from "@/components/user-nav";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/#nasil-calisir", label: "Nasıl çalışır?" },
|
||||
{ href: "/#karsilastirma", label: "Karşılaştır" },
|
||||
{ href: "/#sss", label: "SSS" },
|
||||
];
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-6 z-50 mt-6 print:hidden">
|
||||
@@ -32,17 +27,7 @@ export function SiteHeader() {
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 sm:flex">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<KatalogArama />
|
||||
{/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */}
|
||||
<Suspense fallback={<UserNavFallback />}>
|
||||
<UserNav />
|
||||
|
||||
156
src/components/universite-konum-haritasi.tsx
Normal file
156
src/components/universite-konum-haritasi.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { siApple, siGooglemaps } from "simple-icons";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import {
|
||||
HARITA_GENISLIK,
|
||||
HARITA_UST_KIRPMA,
|
||||
HARITA_YUKSEKLIK,
|
||||
normalizeIlAdi,
|
||||
uniKonum,
|
||||
} from "@/lib/harita";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
|
||||
function HaritaMarkaIkonu({ marka }: { marka: string }) {
|
||||
const ikon =
|
||||
marka === "Google" ? siGooglemaps : marka === "Apple" ? siApple : null;
|
||||
|
||||
if (ikon) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="size-4"
|
||||
style={{ color: `#${ikon.hex}` }}
|
||||
aria-hidden
|
||||
>
|
||||
<path d={ikon.path} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex size-4 items-center justify-center rounded-sm bg-[#fc3f1d] text-[11px] font-bold leading-none text-white"
|
||||
aria-hidden
|
||||
>
|
||||
Я
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function UniversiteKonumHaritasi({
|
||||
universite,
|
||||
il,
|
||||
}: {
|
||||
universite: string;
|
||||
il: string | null;
|
||||
}) {
|
||||
const konum = uniKonum(universite, il);
|
||||
const ilAdi = il ? trBaslikDuzeni(il) : null;
|
||||
const arama = `${universite}${ilAdi ? ` ${ilAdi}` : ""}`;
|
||||
const sorgu = encodeURIComponent(arama);
|
||||
const haritaBaglantilari = [
|
||||
{
|
||||
ad: "Google",
|
||||
href: `https://www.google.com/maps/search/?api=1&query=${sorgu}`,
|
||||
},
|
||||
{ ad: "Yandex", href: `https://yandex.com.tr/maps/?text=${sorgu}` },
|
||||
{ ad: "Apple", href: `https://maps.apple.com/?q=${sorgu}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="universite-konumu"
|
||||
className="relative col-span-2 min-h-44 lg:col-span-1 lg:col-start-3 lg:row-start-1 lg:row-span-2 lg:min-h-0"
|
||||
>
|
||||
<h2 id="universite-konumu" className="sr-only">
|
||||
{universite} kampüs konumu
|
||||
</h2>
|
||||
|
||||
{konum ? (
|
||||
<div className="flex h-full min-h-44 items-start gap-2">
|
||||
<div className="relative h-full min-h-44 min-w-0 flex-1">
|
||||
<svg
|
||||
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
role="img"
|
||||
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
|
||||
>
|
||||
{cities.map((city) => {
|
||||
const secili = il ? normalizeIlAdi(city.name) === il : false;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
d={city.path}
|
||||
fill={
|
||||
secili
|
||||
? "oklch(0.809 0.105 251.8)"
|
||||
: "oklch(0.929 0.013 255.5)"
|
||||
}
|
||||
stroke="white"
|
||||
strokeWidth={secili ? 1.8 : 1}
|
||||
>
|
||||
<title>{city.name}</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
<g aria-hidden>
|
||||
<circle
|
||||
cx={konum.x}
|
||||
cy={konum.y}
|
||||
r={18}
|
||||
fill="oklch(0.623 0.188 259.8 / 18%)"
|
||||
/>
|
||||
<circle
|
||||
cx={konum.x}
|
||||
cy={konum.y}
|
||||
r={9}
|
||||
fill="oklch(0.623 0.188 259.8)"
|
||||
stroke="white"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<circle cx={konum.x} cy={konum.y} r={2.5} fill="white" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 flex-col items-end gap-1"
|
||||
aria-label="Harita uygulamaları"
|
||||
>
|
||||
{haritaBaglantilari.map((baglanti) => (
|
||||
<a
|
||||
key={baglanti.ad}
|
||||
href={baglanti.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`${baglanti.ad} Haritalar'da aç`}
|
||||
aria-label={`${universite} konumunu ${baglanti.ad} Haritalar'da aç`}
|
||||
className="inline-flex size-11 items-center justify-center rounded-full bg-white/95 shadow-sm backdrop-blur transition-colors duration-200 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<HaritaMarkaIkonu marka={baglanti.ad} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full min-h-44 flex-col items-end justify-center gap-1"
|
||||
aria-label="Harita uygulamaları"
|
||||
>
|
||||
{haritaBaglantilari.map((baglanti) => (
|
||||
<a
|
||||
key={baglanti.ad}
|
||||
href={baglanti.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`${baglanti.ad} Haritalar'da aç`}
|
||||
aria-label={`${universite} konumunu ${baglanti.ad} Haritalar'da aç`}
|
||||
className="inline-flex size-11 items-center justify-center rounded-full bg-white shadow-sm transition-colors duration-200 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<HaritaMarkaIkonu marka={baglanti.ad} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
233
src/lib/db.ts
233
src/lib/db.ts
@@ -31,16 +31,20 @@ export const PUAN_TURLERI = {
|
||||
|
||||
export type PuanTuruKey = keyof typeof PUAN_TURLERI;
|
||||
|
||||
export type DilimKey = "hayal" | "dengeli" | "garanti";
|
||||
|
||||
export type RankResults = {
|
||||
hayal: Program[];
|
||||
dengeli: Program[];
|
||||
garanti: Program[];
|
||||
/** Dilim başına pencereye düşen TOPLAM program sayısı (limit'ten bağımsız) */
|
||||
toplam: Record<DilimKey, number>;
|
||||
};
|
||||
|
||||
// Dev'de hot-reload başına yeni bağlantı açılmasın diye global cache
|
||||
const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database };
|
||||
|
||||
function getDb(): Database.Database {
|
||||
export function getDb(): Database.Database {
|
||||
if (!globalForDb.yokatlasDb) {
|
||||
globalForDb.yokatlasDb = new Database(
|
||||
path.join(process.cwd(), "data", "yokatlas.db"),
|
||||
@@ -50,12 +54,12 @@ function getDb(): Database.Database {
|
||||
return globalForDb.yokatlasDb;
|
||||
}
|
||||
|
||||
const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
|
||||
export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
|
||||
sira2025, sira2024, sira2023, sira2022, sira2021,
|
||||
puan2025, kontenjan2025, yerlesen2025`;
|
||||
|
||||
// 2025 sıralaması yoksa (az yerleşen/yeni program) 2024'e düşer
|
||||
const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
|
||||
export const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
|
||||
|
||||
/**
|
||||
* Kullanıcının başarı sıralamasına göre programları üç dilime ayırır.
|
||||
@@ -74,20 +78,45 @@ function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
|
||||
return { sql: "", args: [] };
|
||||
}
|
||||
|
||||
export function searchByRank(
|
||||
/**
|
||||
* Dilim sınırları. Pencereler çarpan bazlı DEĞİL, açık uçlu: hayal adayın
|
||||
* üstündeki her şeyi, garanti 1.4× eşiğinin altındaki her şeyi kapsar.
|
||||
* "Yakından uzağa" sıralama + limit/offset sayesinde uçtaki gerçekçi olmayan
|
||||
* programlar ancak sayfalandıkça görünür; eski ×0.5/×3 pencereleri küçük
|
||||
* sıralamalarda (ör. 87) hiç sonuç bırakmıyordu.
|
||||
*/
|
||||
export function dilimAraligi(
|
||||
sira: number,
|
||||
dilim: DilimKey
|
||||
): { alt: number; ust: number | null } {
|
||||
const dengeliUst = Math.round(sira * 1.4);
|
||||
if (dilim === "hayal") return { alt: 1, ust: sira - 1 };
|
||||
if (dilim === "dengeli") return { alt: sira, ust: dengeliUst };
|
||||
return { alt: dengeliUst + 1, ust: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tek dilimi "yakından uzağa" sıralı ve sayfalı getirir: hayal adayın
|
||||
* sıralamasından yukarı (taban DESC), dengeli/garanti aşağı (taban ASC).
|
||||
* toplam, limit'ten bağımsız olarak dilimdeki tüm eşleşme sayısıdır.
|
||||
*/
|
||||
export function dilimAra(
|
||||
sira: number,
|
||||
turKey: PuanTuruKey,
|
||||
dilim: DilimKey,
|
||||
opts: {
|
||||
il?: string;
|
||||
iller?: string[];
|
||||
uniturGrubu?: UniturGrubu;
|
||||
limitPerBucket?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
): RankResults {
|
||||
): { programlar: Program[]; toplam: number } {
|
||||
const db = getDb();
|
||||
const tur = PUAN_TURLERI[turKey];
|
||||
const onlisans = turKey === "tyt" ? 1 : 0;
|
||||
const limitPerBucket = opts.limitPerBucket ?? 12;
|
||||
const limit = opts.limit ?? 12;
|
||||
const offset = opts.offset ?? 0;
|
||||
|
||||
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
|
||||
const illerListe = [
|
||||
@@ -99,55 +128,64 @@ export function searchByRank(
|
||||
? `AND il IN (${illerListe.map(() => "?").join(",")})`
|
||||
: "";
|
||||
const unitur = uniturFiltreSql(opts.uniturGrubu);
|
||||
const ilArgs = [...illerListe, ...unitur.args];
|
||||
|
||||
const { alt, ust } = dilimAraligi(sira, dilim);
|
||||
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
|
||||
const siraKosulu =
|
||||
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
|
||||
const siraArgs = ust != null ? [alt, ust] : [alt];
|
||||
const args = [tur, onlisans, ...illerListe, ...unitur.args, ...siraArgs];
|
||||
|
||||
const base = `FROM programs
|
||||
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql}
|
||||
AND ${EFEKTIF_SIRA} IS NOT NULL
|
||||
AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
|
||||
AND ${siraKosulu}`;
|
||||
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
|
||||
const yon = dilim === "hayal" ? "DESC" : "ASC";
|
||||
|
||||
// hayal: kullanıcıdan daha iyi taban sıralaması (sınıra en yakın olanlar önce)
|
||||
const hayal = db
|
||||
const programlar = db
|
||||
.prepare(
|
||||
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} DESC LIMIT ?`
|
||||
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(
|
||||
tur,
|
||||
onlisans,
|
||||
...ilArgs,
|
||||
Math.round(sira * 0.5),
|
||||
sira - 1,
|
||||
limitPerBucket
|
||||
);
|
||||
.all(...args, limit, offset) as Program[];
|
||||
const toplam = (
|
||||
db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as {
|
||||
adet: number;
|
||||
}
|
||||
).adet;
|
||||
|
||||
// dengeli: tabanı kullanıcının sıralaması civarında
|
||||
const dengeli = db
|
||||
.prepare(`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ASC LIMIT ?`)
|
||||
.all(
|
||||
tur,
|
||||
onlisans,
|
||||
...ilArgs,
|
||||
sira,
|
||||
Math.round(sira * 1.4),
|
||||
limitPerBucket
|
||||
);
|
||||
return { programlar, toplam };
|
||||
}
|
||||
|
||||
// garanti: tabanı belirgin şekilde altında
|
||||
const garanti = db
|
||||
.prepare(`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ASC LIMIT ?`)
|
||||
.all(
|
||||
tur,
|
||||
onlisans,
|
||||
...ilArgs,
|
||||
Math.round(sira * 1.4) + 1,
|
||||
Math.round(sira * 3),
|
||||
limitPerBucket
|
||||
);
|
||||
export function searchByRank(
|
||||
sira: number,
|
||||
turKey: PuanTuruKey,
|
||||
opts: {
|
||||
il?: string;
|
||||
iller?: string[];
|
||||
uniturGrubu?: UniturGrubu;
|
||||
limitPerBucket?: number;
|
||||
} = {}
|
||||
): RankResults {
|
||||
const ortak = {
|
||||
il: opts.il,
|
||||
iller: opts.iller,
|
||||
uniturGrubu: opts.uniturGrubu,
|
||||
limit: opts.limitPerBucket ?? 12,
|
||||
};
|
||||
const hayal = dilimAra(sira, turKey, "hayal", ortak);
|
||||
const dengeli = dilimAra(sira, turKey, "dengeli", ortak);
|
||||
const garanti = dilimAra(sira, turKey, "garanti", ortak);
|
||||
|
||||
return {
|
||||
hayal: hayal as Program[],
|
||||
dengeli: dengeli as Program[],
|
||||
garanti: garanti as Program[],
|
||||
hayal: hayal.programlar,
|
||||
dengeli: dengeli.programlar,
|
||||
garanti: garanti.programlar,
|
||||
toplam: {
|
||||
hayal: hayal.toplam,
|
||||
dengeli: dengeli.toplam,
|
||||
garanti: garanti.toplam,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,39 +193,79 @@ export type SihirbazFacetleri = {
|
||||
kategoriler: { ad: string; adet: number }[];
|
||||
iller: { il: string; adet: number }[];
|
||||
uniturler: { grup: UniturGrubu; adet: number }[];
|
||||
/** Penceredeki (filtre uygulanmamış) toplam program sayısı */
|
||||
toplam: number;
|
||||
};
|
||||
|
||||
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal alt sınırından
|
||||
// (sira*0.5) garanti üst sınırına (sira*3) kadar. Böylece 500B sıradaki aday Tıp
|
||||
// bubble'ı görmez; 5B sıradaki görür.
|
||||
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için
|
||||
// gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık
|
||||
// sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı
|
||||
// görmez; 5B sıradaki görür.
|
||||
//
|
||||
// Kademeli (cascade) filtre: adımlar birbirini süzer. Kategoriler yalnızca
|
||||
// pencereden; iller seçili kategorilerden; üniversite tipi kategori+il'den
|
||||
// hesaplanır. Böylece "Mühendislik + İzmir" seçen aday 3. adımda yalnızca
|
||||
// İzmir'deki mühendislik programlarının devlet/vakıf dağılımını görür.
|
||||
export function rankWindowFacets(
|
||||
sira: number,
|
||||
turKey: PuanTuruKey
|
||||
turKey: PuanTuruKey,
|
||||
secim: { kategoriler?: string[]; iller?: string[] } = {}
|
||||
): SihirbazFacetleri {
|
||||
const db = getDb();
|
||||
const tur = PUAN_TURLERI[turKey];
|
||||
const onlisans = turKey === "tyt" ? 1 : 0;
|
||||
const altSinir = Math.round(sira * 0.5);
|
||||
const ustSinir = Math.round(sira * 3);
|
||||
|
||||
const base = `FROM programs
|
||||
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
|
||||
AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
|
||||
|
||||
// isim bazında say → JS'te kategoriye çevir (bir isim çok kategoriye girebilir)
|
||||
const isimSayilari = db
|
||||
.prepare(`SELECT isim, COUNT(*) AS adet ${base} GROUP BY isim`)
|
||||
.all(tur, onlisans, altSinir, ustSinir) as {
|
||||
const satirlar = db
|
||||
.prepare(
|
||||
`SELECT isim, il, unitur FROM programs
|
||||
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
|
||||
AND ${EFEKTIF_SIRA} >= ?`
|
||||
)
|
||||
.all(tur, onlisans, altSinir) as {
|
||||
isim: string;
|
||||
adet: number;
|
||||
il: string | null;
|
||||
unitur: string | null;
|
||||
}[];
|
||||
|
||||
const kategoriSay = new Map<string, number>();
|
||||
for (const { isim, adet } of isimSayilari) {
|
||||
for (const k of kategoriBul(isim)) {
|
||||
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + adet);
|
||||
// isim → kategori eşlemesi pahalı (12 regex); tekrar eden isimler için önbellek
|
||||
const kategoriCache = new Map<string, string[]>();
|
||||
const kategorileriGetir = (isim: string): string[] => {
|
||||
let k = kategoriCache.get(isim);
|
||||
if (!k) {
|
||||
k = kategoriBul(isim);
|
||||
kategoriCache.set(isim, k);
|
||||
}
|
||||
return k;
|
||||
};
|
||||
|
||||
const seciliKategoriler = secim.kategoriler ?? [];
|
||||
const kategoriUyar = (isim: string) =>
|
||||
seciliKategoriler.length === 0 ||
|
||||
kategorileriGetir(isim).some((k) => seciliKategoriler.includes(k));
|
||||
const seciliIller = new Set(secim.iller ?? []);
|
||||
const ilUyar = (il: string | null) =>
|
||||
seciliIller.size === 0 || (il != null && seciliIller.has(il));
|
||||
|
||||
const kategoriSay = new Map<string, number>();
|
||||
const ilSay = new Map<string, number>();
|
||||
let devlet = 0;
|
||||
let vakif = 0;
|
||||
|
||||
for (const { isim, il, unitur } of satirlar) {
|
||||
// 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz)
|
||||
for (const k of kategorileriGetir(isim)) {
|
||||
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1);
|
||||
}
|
||||
if (!kategoriUyar(isim)) continue;
|
||||
// 2. adım: iller seçili kategorilerle süzülür
|
||||
if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1);
|
||||
if (!ilUyar(il)) continue;
|
||||
// 3. adım: üniversite tipi kategori + il ile süzülür
|
||||
if (unitur === "DEVLET") devlet += 1;
|
||||
else if (unitur) vakif += 1;
|
||||
}
|
||||
|
||||
const kategoriler = KATEGORILER.map((ad) => ({
|
||||
ad,
|
||||
adet: kategoriSay.get(ad) ?? 0,
|
||||
@@ -195,33 +273,14 @@ export function rankWindowFacets(
|
||||
.filter((k) => k.adet >= 3) // gürültüyü ele
|
||||
.sort((a, b) => b.adet - a.adet);
|
||||
|
||||
const iller = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT il, COUNT(*) AS adet ${base} AND il IS NOT NULL AND il <> ''
|
||||
GROUP BY il ORDER BY adet DESC LIMIT 15`
|
||||
)
|
||||
.all(tur, onlisans, altSinir, ustSinir) as {
|
||||
il: string;
|
||||
adet: number;
|
||||
}[]
|
||||
).filter((r) => r.adet > 0);
|
||||
const iller = [...ilSay.entries()]
|
||||
.map(([il, adet]) => ({ il, adet }))
|
||||
.sort((a, b) => b.adet - a.adet)
|
||||
.slice(0, 15);
|
||||
|
||||
const uniturSatirlari = db
|
||||
.prepare(`SELECT unitur, COUNT(*) AS adet ${base} GROUP BY unitur`)
|
||||
.all(tur, onlisans, altSinir, ustSinir) as {
|
||||
unitur: string | null;
|
||||
adet: number;
|
||||
}[];
|
||||
let devlet = 0;
|
||||
let vakif = 0;
|
||||
for (const { unitur, adet } of uniturSatirlari) {
|
||||
if (unitur === "DEVLET") devlet += adet;
|
||||
else if (unitur) vakif += adet;
|
||||
}
|
||||
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
|
||||
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });
|
||||
if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif });
|
||||
|
||||
return { kategoriler, iller, uniturler };
|
||||
return { kategoriler, iller, uniturler, toplam: satirlar.length };
|
||||
}
|
||||
|
||||
384
src/lib/katalog.ts
Normal file
384
src/lib/katalog.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
getDb,
|
||||
SELECT_COLS,
|
||||
EFEKTIF_SIRA,
|
||||
type Program,
|
||||
} from "./db";
|
||||
import { kategoriBul, type Kategori } from "./kategoriler";
|
||||
import { uniAdiNormalize } from "./harita";
|
||||
import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug";
|
||||
|
||||
/**
|
||||
* Programatik SEO sayfalarının veri katmanı (/bolum, /universite, hub'lar).
|
||||
*
|
||||
* Slug haritaları modül-scope'ta lazy kurulur: DB readonly açıldığı için
|
||||
* slugs tablosu yazılamaz; SELECT DISTINCT (1.5K satır) süreç başına bir kez
|
||||
* <10ms sürer. Harita değerleri HAM isim/universite dizeleridir — sorgular
|
||||
* `WHERE isim IN (?, ...)` ile birebir eşleşip index'e vurur (WHERE içinde
|
||||
* TRIM/fonksiyon kullanmak index'i öldürür).
|
||||
*/
|
||||
|
||||
// ---------- Bölüm tarafı ----------
|
||||
|
||||
export type BolumOzet = {
|
||||
slug: string;
|
||||
ad: string;
|
||||
programSayisi: number;
|
||||
kategoriler: Kategori[];
|
||||
lisans: boolean;
|
||||
onlisans: boolean;
|
||||
/** MIN(COALESCE(sira2025, sira2024)) — en iyi (en küçük) taban sıralaması */
|
||||
enIyiSira: number | null;
|
||||
};
|
||||
|
||||
export type BolumStats = {
|
||||
toplam: number;
|
||||
devlet: number;
|
||||
vakif: number;
|
||||
/** unitur boş/NULL olan satırlar — vakıf SAYILMAZ, ayrı gösterilir */
|
||||
bilinmeyenTur: number;
|
||||
minSira: number | null;
|
||||
maxSira: number | null;
|
||||
minPuan: number | null;
|
||||
maxPuan: number | null;
|
||||
ilSayisi: number;
|
||||
};
|
||||
|
||||
export type BolumDetay = BolumOzet & {
|
||||
lisansProgramlari: Program[];
|
||||
onlisansProgramlari: Program[];
|
||||
stats: BolumStats;
|
||||
};
|
||||
|
||||
type BolumKayit = { slug: string; ad: string; hamIsimler: string[] };
|
||||
|
||||
const globalKatalog = globalThis as unknown as {
|
||||
bolumMap?: Map<string, BolumKayit>;
|
||||
bolumOzetler?: BolumOzet[];
|
||||
uniMap?: Map<string, UniKayit>;
|
||||
uniOzetler?: UniOzet[];
|
||||
};
|
||||
|
||||
function bolumMapGetir(): Map<string, BolumKayit> {
|
||||
if (!globalKatalog.bolumMap) {
|
||||
const rows = getDb()
|
||||
.prepare("SELECT DISTINCT isim FROM programs")
|
||||
.all() as { isim: string }[];
|
||||
const map = new Map<string, BolumKayit>();
|
||||
for (const { isim } of rows) {
|
||||
const baz = bolumBazAdi(isim);
|
||||
if (!baz) continue;
|
||||
const slug = turkishSlugify(baz);
|
||||
if (!slug) continue;
|
||||
const kayit = map.get(slug);
|
||||
if (kayit) kayit.hamIsimler.push(isim);
|
||||
else map.set(slug, { slug, ad: baz, hamIsimler: [isim] });
|
||||
}
|
||||
if (map.size === 0) {
|
||||
// generateStaticParams boş dizi dönerse build hatası — boş DB'yi erken yakala
|
||||
throw new Error("katalog: programs tablosu boş görünüyor");
|
||||
}
|
||||
globalKatalog.bolumMap = map;
|
||||
}
|
||||
return globalKatalog.bolumMap;
|
||||
}
|
||||
|
||||
export function getAllBolumler(): BolumOzet[] {
|
||||
if (!globalKatalog.bolumOzetler) {
|
||||
const map = bolumMapGetir();
|
||||
// Tek taramada tüm bölümlerin özeti: 23K satır, süreç başına bir kez
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT isim, onlisans, ${EFEKTIF_SIRA} AS efektif FROM programs`,
|
||||
)
|
||||
.all() as { isim: string; onlisans: number; efektif: number | null }[];
|
||||
|
||||
const ozet = new Map<string, BolumOzet>();
|
||||
const slugByIsim = new Map<string, string>();
|
||||
for (const kayit of map.values()) {
|
||||
for (const ham of kayit.hamIsimler) slugByIsim.set(ham, kayit.slug);
|
||||
ozet.set(kayit.slug, {
|
||||
slug: kayit.slug,
|
||||
ad: kayit.ad,
|
||||
programSayisi: 0,
|
||||
kategoriler: kategoriBul(kayit.ad),
|
||||
lisans: false,
|
||||
onlisans: false,
|
||||
enIyiSira: null,
|
||||
});
|
||||
}
|
||||
for (const row of rows) {
|
||||
const slug = slugByIsim.get(row.isim);
|
||||
if (!slug) continue;
|
||||
const b = ozet.get(slug)!;
|
||||
b.programSayisi += 1;
|
||||
if (row.onlisans === 1) b.onlisans = true;
|
||||
else b.lisans = true;
|
||||
if (row.efektif != null && (b.enIyiSira == null || row.efektif < b.enIyiSira)) {
|
||||
b.enIyiSira = row.efektif;
|
||||
}
|
||||
}
|
||||
globalKatalog.bolumOzetler = [...ozet.values()].sort((a, b) =>
|
||||
a.ad.localeCompare(b.ad, "tr-TR"),
|
||||
);
|
||||
}
|
||||
return globalKatalog.bolumOzetler;
|
||||
}
|
||||
|
||||
export function getAllBolumSlugs(): string[] {
|
||||
return [...bolumMapGetir().keys()];
|
||||
}
|
||||
|
||||
export function getBolumBySlug(slug: string): BolumDetay | null {
|
||||
const kayit = bolumMapGetir().get(slug);
|
||||
if (!kayit) return null;
|
||||
|
||||
const placeholders = kayit.hamIsimler.map(() => "?").join(",");
|
||||
const programlar = getDb()
|
||||
.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM programs
|
||||
WHERE isim IN (${placeholders})
|
||||
ORDER BY (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`,
|
||||
)
|
||||
.all(...kayit.hamIsimler) as (Program & { onlisans?: number })[];
|
||||
|
||||
// onlisans SELECT_COLS içinde yok; tur üzerinden ayır (TYT = önlisans)
|
||||
const lisansProgramlari = programlar.filter((p) => p.tur !== "TYT");
|
||||
const onlisansProgramlari = programlar.filter((p) => p.tur === "TYT");
|
||||
|
||||
const iller = new Set<string>();
|
||||
let devlet = 0;
|
||||
let vakif = 0;
|
||||
let bilinmeyenTur = 0;
|
||||
let minSira: number | null = null;
|
||||
let maxSira: number | null = null;
|
||||
let minPuan: number | null = null;
|
||||
let maxPuan: number | null = null;
|
||||
for (const p of programlar) {
|
||||
if (p.il) iller.add(p.il);
|
||||
if (p.unitur === "DEVLET") devlet += 1;
|
||||
else if (p.unitur) vakif += 1;
|
||||
else bilinmeyenTur += 1;
|
||||
const efektif = p.sira2025 ?? p.sira2024;
|
||||
if (efektif != null) {
|
||||
if (minSira == null || efektif < minSira) minSira = efektif;
|
||||
if (maxSira == null || efektif > maxSira) maxSira = efektif;
|
||||
}
|
||||
if (p.puan2025 != null) {
|
||||
if (minPuan == null || p.puan2025 < minPuan) minPuan = p.puan2025;
|
||||
if (maxPuan == null || p.puan2025 > maxPuan) maxPuan = p.puan2025;
|
||||
}
|
||||
}
|
||||
|
||||
const ozet = getAllBolumler().find((b) => b.slug === slug)!;
|
||||
return {
|
||||
...ozet,
|
||||
lisansProgramlari,
|
||||
onlisansProgramlari,
|
||||
stats: {
|
||||
toplam: programlar.length,
|
||||
devlet,
|
||||
vakif,
|
||||
bilinmeyenTur,
|
||||
minSira,
|
||||
maxSira,
|
||||
minPuan,
|
||||
maxPuan,
|
||||
ilSayisi: iller.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Aynı kategorideki diğer bölümler, program sayısına göre (kendisi hariç). */
|
||||
export function ilgiliBolumler(slug: string, limit = 8): BolumOzet[] {
|
||||
const hepsi = getAllBolumler();
|
||||
const kendi = hepsi.find((b) => b.slug === slug);
|
||||
if (!kendi) return [];
|
||||
const kendiKategoriler = new Set<string>(kendi.kategoriler);
|
||||
return hepsi
|
||||
.filter(
|
||||
(b) =>
|
||||
b.slug !== slug &&
|
||||
(kendiKategoriler.size === 0 ||
|
||||
b.kategoriler.some((k) => kendiKategoriler.has(k))),
|
||||
)
|
||||
.sort((a, b) => b.programSayisi - a.programSayisi)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// ---------- Üniversite tarafı ----------
|
||||
|
||||
export type UniOzet = {
|
||||
slug: string;
|
||||
/** tr-TR başlık düzeninde görünen ad (İstanbul Teknik Üniversitesi) */
|
||||
ad: string;
|
||||
il: string | null;
|
||||
unitur: string | null;
|
||||
programSayisi: number;
|
||||
fakulteSayisi: number;
|
||||
};
|
||||
|
||||
export type UniDetay = UniOzet & {
|
||||
fakulteler: { fakulte: string; programlar: Program[] }[];
|
||||
stats: {
|
||||
minSira: number | null;
|
||||
toplamKontenjan: number;
|
||||
devletMi: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type UniKayit = { slug: string; ad: string; hamAdlar: string[] };
|
||||
|
||||
function uniMapGetir(): Map<string, UniKayit> {
|
||||
if (!globalKatalog.uniMap) {
|
||||
const rows = getDb()
|
||||
.prepare("SELECT DISTINCT universite FROM programs")
|
||||
.all() as { universite: string }[];
|
||||
const map = new Map<string, UniKayit>();
|
||||
for (const { universite } of rows) {
|
||||
const normal = uniAdiNormalize(universite);
|
||||
if (!normal) continue;
|
||||
const slug = turkishSlugify(normal);
|
||||
if (!slug) continue;
|
||||
const kayit = map.get(slug);
|
||||
if (kayit) kayit.hamAdlar.push(universite);
|
||||
else
|
||||
map.set(slug, {
|
||||
slug,
|
||||
ad: trBaslikDuzeni(normal),
|
||||
hamAdlar: [universite],
|
||||
});
|
||||
}
|
||||
if (map.size === 0) {
|
||||
throw new Error("katalog: programs tablosunda üniversite yok");
|
||||
}
|
||||
globalKatalog.uniMap = map;
|
||||
}
|
||||
return globalKatalog.uniMap;
|
||||
}
|
||||
|
||||
export function getAllUniversiteler(): UniOzet[] {
|
||||
if (!globalKatalog.uniOzetler) {
|
||||
const map = uniMapGetir();
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT universite, MAX(il) AS il, MAX(unitur) AS unitur,
|
||||
COUNT(*) AS adet
|
||||
FROM programs GROUP BY universite`,
|
||||
)
|
||||
.all() as {
|
||||
universite: string;
|
||||
il: string | null;
|
||||
unitur: string | null;
|
||||
adet: number;
|
||||
}[];
|
||||
// Fakülte sayısı: aynı üniversitenin iki yazımı ("X ÜNİV." / "X ÜNİV. (İL)")
|
||||
// birleştiği için GROUP BY universite üzerinden saymak çift sayardı;
|
||||
// (slug, fakulte) çifti üzerinden kesin sayılır.
|
||||
const fakulteRows = getDb()
|
||||
.prepare(
|
||||
"SELECT DISTINCT universite, fakulte FROM programs WHERE fakulte IS NOT NULL",
|
||||
)
|
||||
.all() as { universite: string; fakulte: string }[];
|
||||
|
||||
const bySlug = new Map<string, UniOzet>();
|
||||
const slugByHam = new Map<string, string>();
|
||||
for (const kayit of map.values()) {
|
||||
for (const ham of kayit.hamAdlar) slugByHam.set(ham, kayit.slug);
|
||||
bySlug.set(kayit.slug, {
|
||||
slug: kayit.slug,
|
||||
ad: kayit.ad,
|
||||
il: null,
|
||||
unitur: null,
|
||||
programSayisi: 0,
|
||||
fakulteSayisi: 0,
|
||||
});
|
||||
}
|
||||
for (const row of rows) {
|
||||
const slug = slugByHam.get(row.universite);
|
||||
if (!slug) continue;
|
||||
const u = bySlug.get(slug)!;
|
||||
u.programSayisi += row.adet;
|
||||
if (!u.il && row.il) u.il = row.il;
|
||||
if (!u.unitur && row.unitur) u.unitur = row.unitur;
|
||||
}
|
||||
const fakulteSetleri = new Map<string, Set<string>>();
|
||||
for (const row of fakulteRows) {
|
||||
const slug = slugByHam.get(row.universite);
|
||||
if (!slug) continue;
|
||||
let set = fakulteSetleri.get(slug);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
fakulteSetleri.set(slug, set);
|
||||
}
|
||||
set.add(row.fakulte);
|
||||
}
|
||||
for (const [slug, set] of fakulteSetleri) {
|
||||
bySlug.get(slug)!.fakulteSayisi = set.size;
|
||||
}
|
||||
globalKatalog.uniOzetler = [...bySlug.values()].sort((a, b) =>
|
||||
a.ad.localeCompare(b.ad, "tr-TR"),
|
||||
);
|
||||
}
|
||||
return globalKatalog.uniOzetler;
|
||||
}
|
||||
|
||||
export function getAllUniversiteSlugs(): string[] {
|
||||
return [...uniMapGetir().keys()];
|
||||
}
|
||||
|
||||
export function getUniversiteBySlug(slug: string): UniDetay | null {
|
||||
const kayit = uniMapGetir().get(slug);
|
||||
if (!kayit) return null;
|
||||
|
||||
const placeholders = kayit.hamAdlar.map(() => "?").join(",");
|
||||
const programlar = getDb()
|
||||
.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM programs
|
||||
WHERE universite IN (${placeholders})
|
||||
ORDER BY fakulte, (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`,
|
||||
)
|
||||
.all(...kayit.hamAdlar) as Program[];
|
||||
|
||||
const gruplar = new Map<string, Program[]>();
|
||||
let minSira: number | null = null;
|
||||
let toplamKontenjan = 0;
|
||||
for (const p of programlar) {
|
||||
const anahtar = p.fakulte ?? "Diğer";
|
||||
const liste = gruplar.get(anahtar);
|
||||
if (liste) liste.push(p);
|
||||
else gruplar.set(anahtar, [p]);
|
||||
const efektif = p.sira2025 ?? p.sira2024;
|
||||
if (efektif != null && (minSira == null || efektif < minSira)) {
|
||||
minSira = efektif;
|
||||
}
|
||||
toplamKontenjan += p.kontenjan2025 ?? 0;
|
||||
}
|
||||
|
||||
const ozet = getAllUniversiteler().find((u) => u.slug === slug)!;
|
||||
return {
|
||||
...ozet,
|
||||
fakulteler: [...gruplar.entries()]
|
||||
.map(([fakulte, programlar]) => ({ fakulte, programlar }))
|
||||
.sort((a, b) => a.fakulte.localeCompare(b.fakulte, "tr-TR")),
|
||||
stats: {
|
||||
minSira,
|
||||
toplamKontenjan,
|
||||
devletMi: ozet.unitur === "DEVLET",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Yardımcılar ----------
|
||||
|
||||
/** Ham program isminden /bolum sayfası slug'ı (tablolarda çapraz link için). */
|
||||
export function bolumSlugFromIsim(isim: string): string | null {
|
||||
const slug = turkishSlugify(bolumBazAdi(isim));
|
||||
return bolumMapGetir().has(slug) ? slug : null;
|
||||
}
|
||||
|
||||
/** Ham üniversite adından /universite sayfası slug'ı. */
|
||||
export function uniSlugFromAd(universite: string): string | null {
|
||||
const slug = turkishSlugify(uniAdiNormalize(universite));
|
||||
return uniMapGetir().has(slug) ? slug : null;
|
||||
}
|
||||
90
src/lib/rehber.ts
Normal file
90
src/lib/rehber.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { marked } from "marked";
|
||||
|
||||
/**
|
||||
* Rehber makaleleri: content/rehber/*.md dosyaları build sırasında okunur ve
|
||||
* statik HTML'e gömülür (sayfalar generateStaticParams + dynamicParams=false
|
||||
* ile tamamen prerender edilir; runtime'da fs erişimi olmaz).
|
||||
*
|
||||
* Frontmatter elle parse edilir (yalnızca düz `anahtar: değer` satırları) —
|
||||
* 10 makale için gray-matter bağımlılığına gerek yok.
|
||||
*/
|
||||
|
||||
export type RehberOzet = {
|
||||
slug: string;
|
||||
baslik: string;
|
||||
aciklama: string;
|
||||
/** ISO tarih — Article JSON-LD datePublished */
|
||||
tarih: string;
|
||||
};
|
||||
|
||||
export type RehberYazi = RehberOzet & { html: string };
|
||||
|
||||
const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber");
|
||||
|
||||
function parseFrontmatter(raw: string): {
|
||||
meta: Record<string, string>;
|
||||
body: string;
|
||||
} {
|
||||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (!match) return { meta: {}, body: raw };
|
||||
const meta: Record<string, string> = {};
|
||||
for (const satir of match[1].split(/\r?\n/)) {
|
||||
const ayrac = satir.indexOf(":");
|
||||
if (ayrac === -1) continue;
|
||||
const anahtar = satir.slice(0, ayrac).trim();
|
||||
const deger = satir
|
||||
.slice(ayrac + 1)
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, "");
|
||||
if (anahtar) meta[anahtar] = deger;
|
||||
}
|
||||
return { meta, body: raw.slice(match[0].length) };
|
||||
}
|
||||
|
||||
function oku(slug: string): RehberYazi | null {
|
||||
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
|
||||
if (!fs.existsSync(dosya)) return null;
|
||||
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
|
||||
if (!meta.baslik || !meta.aciklama || !meta.tarih) {
|
||||
throw new Error(
|
||||
`rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
slug,
|
||||
baslik: meta.baslik,
|
||||
aciklama: meta.aciklama,
|
||||
tarih: meta.tarih,
|
||||
html: marked.parse(body, { gfm: true, async: false }),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAllRehberSlugs(): string[] {
|
||||
if (!fs.existsSync(REHBER_DIZIN)) return [];
|
||||
return fs
|
||||
.readdirSync(REHBER_DIZIN)
|
||||
.filter((f) => f.endsWith(".md"))
|
||||
.map((f) => f.replace(/\.md$/, ""))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function getRehber(slug: string): RehberYazi | null {
|
||||
// path traversal koruması: slug yalnızca dosya adı olabilir
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) return null;
|
||||
return oku(slug);
|
||||
}
|
||||
|
||||
export function getAllRehberler(): RehberOzet[] {
|
||||
return getAllRehberSlugs()
|
||||
.map((slug) => oku(slug))
|
||||
.filter((y): y is RehberYazi => y !== null)
|
||||
.map(({ slug, baslik, aciklama, tarih }) => ({
|
||||
slug,
|
||||
baslik,
|
||||
aciklama,
|
||||
tarih,
|
||||
}))
|
||||
.sort((a, b) => b.tarih.localeCompare(a.tarih));
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const RISK_ETIKET: Record<RiskSeviyesi, string> = {
|
||||
/**
|
||||
* Deterministik risk: programın efektif taban sıralaması (COALESCE 2025→2024)
|
||||
* adayın sıralamasıyla kıyaslanır. Eşikler db.ts'teki dilim aralıklarıyla
|
||||
* birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, 3×sira]).
|
||||
* birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, ∞)).
|
||||
*/
|
||||
export function riskHesapla(
|
||||
efektifSira: number | null | undefined,
|
||||
|
||||
124
src/lib/seo.tsx
Normal file
124
src/lib/seo.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { URUNLER } from "./credits";
|
||||
|
||||
/**
|
||||
* Kanonik site adresi. NEXT_PUBLIC_APP_URL bilinçli olarak KULLANILMAZ —
|
||||
* o değişken ödeme callback akışına ait ve .env.local'de localhost'a işaret
|
||||
* ediyor. .dockerignore .env* dosyalarını dışladığı için prod build burada
|
||||
* deterministik olarak kolaytercih.com'a düşer.
|
||||
*/
|
||||
export const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ??
|
||||
(process.env.NODE_ENV === "production"
|
||||
? "https://kolaytercih.com"
|
||||
: "http://localhost:3000");
|
||||
|
||||
export const SITE_NAME = "KolayTercih";
|
||||
|
||||
type JsonLdData = Record<string, unknown>;
|
||||
|
||||
export function organizationJsonLd(): JsonLdData {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/kolay-tercih-mark.svg`,
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
email: "destek@kolaytercih.com",
|
||||
contactType: "customer support",
|
||||
availableLanguage: "Turkish",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function webSiteJsonLd(): JsonLdData {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
inLanguage: "tr",
|
||||
};
|
||||
}
|
||||
|
||||
export function softwareApplicationJsonLd(): JsonLdData {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: SITE_NAME,
|
||||
applicationCategory: "EducationalApplication",
|
||||
operatingSystem: "Web",
|
||||
url: SITE_URL,
|
||||
inLanguage: "tr",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: (URUNLER.paket.amountKurus / 100).toFixed(0),
|
||||
priceCurrency: "TRY",
|
||||
description: URUNLER.paket.label,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function faqPageJsonLd(
|
||||
faqs: readonly { question: string; answer: string }[],
|
||||
): JsonLdData {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: faqs.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.answer },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function breadcrumbJsonLd(
|
||||
items: readonly { name: string; path: string }[],
|
||||
): JsonLdData {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: items.map((item, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: item.name,
|
||||
item: `${SITE_URL}${item.path}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function articleJsonLd(opts: {
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
datePublished: string;
|
||||
}): JsonLdData {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: opts.title,
|
||||
description: opts.description,
|
||||
url: `${SITE_URL}${opts.path}`,
|
||||
datePublished: opts.datePublished,
|
||||
inLanguage: "tr",
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON-LD'yi güvenli gömer: `<` kaçışı script-injection'ı engeller. */
|
||||
export function JsonLd({ data }: { data: JsonLdData }) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/lib/slug.ts
Normal file
45
src/lib/slug.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Türkçe güvenli slug. Sıra kritik: önce tr-TR lowercase — düz toLowerCase()
|
||||
* 'İ'yi 'i' + U+0307 (combining dot) yapar ve slug bozulur — sonra ASCII
|
||||
* katlama, en son NFKD ile kalan aksan artıkları temizlenir.
|
||||
*/
|
||||
export function turkishSlugify(s: string): string {
|
||||
return s
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.replace(/ı/g, "i")
|
||||
.replace(/ş/g, "s")
|
||||
.replace(/ğ/g, "g")
|
||||
.replace(/ü/g, "u")
|
||||
.replace(/ö/g, "o")
|
||||
.replace(/ç/g, "c")
|
||||
.replace(/â/g, "a")
|
||||
.replace(/î/g, "i")
|
||||
.replace(/û/g, "u")
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* "Psikoloji (%50 İndirimli) " → "Psikoloji". Burs/dil/kampüs gibi tüm
|
||||
* parantez varyantlarını atar; arama niyeti taban ada gelir, varyantlar
|
||||
* bölüm sayfasında satır olarak listelenir.
|
||||
*/
|
||||
export function bolumBazAdi(isim: string): string {
|
||||
return isim
|
||||
.replace(/\s*\([^)]*\)/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** ALL-CAPS veritabanı adlarını tr-TR başlık düzenine çevirir (İSTANBUL → İstanbul). */
|
||||
export function trBaslikDuzeni(s: string): string {
|
||||
return s
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.split(" ")
|
||||
.map((w) =>
|
||||
w.length > 0 ? w[0].toLocaleUpperCase("tr-TR") + w.slice(1) : w,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user