feat: /sonuc ham tablosuna sihirbaz il + devlet/vakıf filtresi

Vakıf seçen kullanıcı sonuç tablosunda devlet üniversiteleri görüyordu:
tablo sihirbaz seçimlerini hiç kullanmıyordu. sonucHref artık profildeki
seçimlerden il (tekrarlı) ve tip paramlarını URL'e yazar; tablo SSR'da bu
paramlarla filtrelenir, "daha fazla göster" sayfalaması aynı filtreleri
taşır. Sekmelerin üstünde kaldırılabilir filtre çipleri görünür — çip
kaldırmak yalnızca URL'i günceller, localStorage profiline dokunmaz.

Kategori tabloya bilinçli uygulanmaz (DB'de kategori kolonu yok, JS
filtresi offset/toplam semantiğini bozar); kategori AI listesi ve
tadımlıkta uygulanır.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-09 00:12:08 +03:00
parent 33a260017a
commit 0c6c9da56c
6 changed files with 152 additions and 19 deletions

View File

@@ -36,9 +36,19 @@ export function GET(request: NextRequest) {
return NextResponse.json({ error: "Geçersiz offset." }, { status: 400 });
}
// /sonuc tablosunun filtreleri sayfalamada da korunur (bkz. sonucHref)
const iller = [
...new Set(searchParams.getAll("il").filter((i) => i.trim().length > 0)),
].slice(0, 5);
const tipParam = searchParams.get("tip");
const uniturGrubu =
tipParam === "devlet" || tipParam === "vakif" ? tipParam : undefined;
const { programlar, toplam } = dilimAra(sira, tur, dilimParam as DilimKey, {
limit: SAYFA_BOYU,
offset,
iller,
uniturGrubu,
});
// Tablo satırının kullanmadığı alanlar payload'a girmesin
return NextResponse.json({ programlar: programlar.map(programOzetle), toplam });

View File

@@ -28,6 +28,8 @@ export default function SonucPage({
tur?: string;
sihirbaz?: string;
hazir?: string;
il?: string | string[];
tip?: string;
}>;
}) {
return (
@@ -51,6 +53,22 @@ export default function SonucPage({
params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey)
: "say";
// Sihirbaz seçimlerinden gelen tablo filtreleri (bkz. sonucHref):
// il tekrarlı param, tip devlet|vakif allowlist'li
const iller = [
...new Set(
(Array.isArray(params.il)
? params.il
: params.il
? [params.il]
: []
).filter((i) => i.trim().length > 0),
),
].slice(0, 5);
const uniturGrubu =
params.tip === "devlet" || params.tip === "vakif"
? params.tip
: undefined;
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return (
@@ -71,8 +89,14 @@ export default function SonucPage({
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında,
sayfanın ana gövdesi; puan türü navbar'daki sıralama
rozetinden değiştirilir */}
<SonucProgramTablosu sira={sira} tur={turKey} />
rozetinden değiştirilir. Sihirbaz seçimleri (il, devlet/
vakıf) URL paramlarıyla tabloya uygulanır. */}
<SonucProgramTablosu
sira={sira}
tur={turKey}
iller={iller}
uniturGrubu={uniturGrubu}
/>
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */}

View File

@@ -7,11 +7,13 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import {
ChevronDown,
Rocket,
Scale,
ShieldCheck,
X,
} from "lucide-react";
import type {
DilimKey,
@@ -20,7 +22,7 @@ import type {
RankResultsListe,
} from "@/types/yokatlas";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import { bolumSayfaSlug, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -94,21 +96,44 @@ export function ProgramTablosu({
sonuclar,
adaySira,
tur,
filtreIller,
filtreTip,
}: {
sonuclar: RankResultsListe;
adaySira: number;
tur: PuanTuruKey;
filtreIller?: string[];
filtreTip?: "devlet" | "vakif";
}) {
const router = useRouter();
const arama = useSearchParams();
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
varsayilanDilim(sonuclar),
);
const iller = useMemo(() => filtreIller ?? [], [filtreIller]);
// "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.
// Filtre çipini kaldırmak yalnızca URL'i günceller (tek doğruluk kaynağı
// URL); localStorage'taki sihirbaz profiline DOKUNULMAZ — kullanıcı
// tabloda filtreyi kaldırıp tüm sonuçları görebilir, AI listesi seçimleri
// etkilenmez.
function filtreKaldir(tip: "il" | "tip", deger?: string) {
const q = new URLSearchParams(arama.toString());
if (tip === "tip") {
q.delete("tip");
} else {
const kalanlar = q.getAll("il").filter((i) => i !== deger);
q.delete("il");
for (const i of kalanlar) q.append("il", i);
}
router.replace(`?${q}`, { scroll: false });
}
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür/filtre
// 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 kümenin satırları karışmasın.
const [ekstra, setEkstra] = useState(BOS_EKSTRA);
const [yuklenen, setYuklenen] = useState<DilimKey | null>(null);
const veriAnahtari = `${adaySira}-${tur}`;
const veriAnahtari = `${adaySira}-${tur}-${iller.join(",")}-${filtreTip ?? ""}`;
const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari);
if (veriAnahtari !== oncekiAnahtar) {
setOncekiAnahtar(veriAnahtari);
@@ -129,9 +154,15 @@ export function ProgramTablosu({
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim);
try {
const res = await fetch(
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
);
const q = new URLSearchParams({
sira: String(adaySira),
tur,
dilim,
offset: String(offset),
});
for (const il of iller) q.append("il", il);
if (filtreTip) q.set("tip", filtreTip);
const res = await fetch(`/api/programlar?${q}`);
if (!res.ok) return;
const veri = (await res.json()) as { programlar: ProgramListeSatiri[] };
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
@@ -162,6 +193,39 @@ export function ProgramTablosu({
onPointerEnter={preloadTercihHaritasi}
onFocusCapture={preloadTercihHaritasi}
>
{/* Sihirbaz seçimlerinden gelen aktif filtreler — görünür ve tek tek
kaldırılabilir; sayılar neden azaldı sorusunu burada yanıtlar. */}
{iller.length > 0 || filtreTip ? (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400">
Filtreler:
</span>
{filtreTip ? (
<button
type="button"
onClick={() => filtreKaldir("tip")}
title="Üniversite tipi filtresini kaldır"
className="inline-flex cursor-pointer items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors duration-150 hover:border-slate-300 hover:bg-slate-50"
>
{filtreTip === "devlet" ? "Devlet" : "Vakıf"}
<X className="size-3" aria-hidden />
</button>
) : null}
{iller.map((il) => (
<button
key={il}
type="button"
onClick={() => filtreKaldir("il", il)}
title={`${trBaslikDuzeni(il)} filtresini kaldır`}
className="inline-flex cursor-pointer items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors duration-150 hover:border-slate-300 hover:bg-slate-50"
>
{trBaslikDuzeni(il)}
<X className="size-3" aria-hidden />
</button>
))}
</div>
) : null}
<Tabs
value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)}

View File

@@ -1,19 +1,31 @@
import { siraylaProgramAra } from "../liste-queries";
import { ProgramTablosu } from "./program-tablosu";
import { Skeleton } from "@/components/ui/skeleton";
import type { PuanTuruKey } from "@/types/yokatlas";
import type { PuanTuruKey, UniturGrubu } from "@/types/yokatlas";
// /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa
// yalnızca kompoze eder.
export function SonucProgramTablosu({
sira,
tur,
iller,
uniturGrubu,
}: {
sira: number;
tur: PuanTuruKey;
iller?: string[];
uniturGrubu?: UniturGrubu;
}) {
const sonuclar = siraylaProgramAra(sira, tur);
return <ProgramTablosu sonuclar={sonuclar} adaySira={sira} tur={tur} />;
const sonuclar = siraylaProgramAra(sira, tur, { iller, uniturGrubu });
return (
<ProgramTablosu
sonuclar={sonuclar}
adaySira={sira}
tur={tur}
filtreIller={iller}
filtreTip={uniturGrubu}
/>
);
}
export function SonucProgramTablosuSkeleton() {

View File

@@ -5,6 +5,7 @@ import type {
ProgramListeSatiri,
PuanTuruKey,
RankResultsListe,
UniturGrubu,
} from "@/types/yokatlas";
// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin.
@@ -29,13 +30,22 @@ export function programOzetle(p: Program): ProgramListeSatiri {
};
}
// /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
// /sonuc'un ham tablosu: sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır. Sihirbaz
// seçimlerinden il + üniversite tipi URL üzerinden buraya taşınır ve SQL'de
// filtrelenir; kategori bilinçli olarak uygulanmaz (DB'de kategori kolonu
// yok, JS filtresi offset/toplam semantiğini bozar — kategori AI listesi ve
// tadımlıkta uygulanır).
export function siraylaProgramAra(
sira: number,
tur: PuanTuruKey,
opts: { iller?: string[]; uniturGrubu?: UniturGrubu } = {},
): RankResultsListe {
const ham = searchByRank(sira, tur, { limitPerBucket: 20 });
const ham = searchByRank(sira, tur, {
limitPerBucket: 20,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
});
return {
hayal: ham.hayal.map(programOzetle),
dengeli: ham.dengeli.map(programOzetle),

View File

@@ -126,16 +126,29 @@ export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null {
return { sira, tur: p.tur, secimler };
}
/** Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu kullanır. */
/**
* Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu
* kullanır. Profilde sihirbaz seçimleri varsa il + üniversite tipi de URL'e
* yazılır: /sonuc'un ham tablosu SSR'da bu paramlarla filtrelenir (tek
* doğruluk kaynağı URL; localStorage profili yalnızca URL'i üretir).
*/
export function sonucHref(
profil: Pick<TercihProfili, "sira" | "tur">,
profil: Pick<TercihProfili, "sira" | "tur"> & {
secimler?: SihirbazSecimleri;
},
ekstra?: Record<string, string>,
): string {
const q = new URLSearchParams({
sira: String(profil.sira),
tur: profil.tur,
...ekstra,
});
if (profil.secimler) {
for (const il of profil.secimler.iller) q.append("il", il);
if (profil.secimler.universiteTipi !== "farketmez") {
q.set("tip", profil.secimler.universiteTipi);
}
}
for (const [k, v] of Object.entries(ekstra ?? {})) q.set(k, v);
return `/sonuc?${q}`;
}