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

This commit is contained in:
bilalgursen
2026-08-01 17:28:38 +03:00
parent 569b9e5da2
commit 8cf0c3267d
76 changed files with 5050 additions and 255 deletions

View 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>
);
}

View 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 ı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>
);
}

View File

@@ -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"

View 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">
&quot;{temizSorgu}&quot; 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>
);
}

View File

@@ -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 ? (

View 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>
);
}

View 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>
);
}

View File

@@ -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}

View 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"
>
Ö 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>
);
}

View File

@@ -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 />

View 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>
);
}