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 }); 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, { const { programlar, toplam } = dilimAra(sira, tur, dilimParam as DilimKey, {
limit: SAYFA_BOYU, limit: SAYFA_BOYU,
offset, offset,
iller,
uniturGrubu,
}); });
// Tablo satırının kullanmadığı alanlar payload'a girmesin // Tablo satırının kullanmadığı alanlar payload'a girmesin
return NextResponse.json({ programlar: programlar.map(programOzetle), toplam }); return NextResponse.json({ programlar: programlar.map(programOzetle), toplam });

View File

@@ -28,6 +28,8 @@ export default function SonucPage({
tur?: string; tur?: string;
sihirbaz?: string; sihirbaz?: string;
hazir?: string; hazir?: string;
il?: string | string[];
tip?: string;
}>; }>;
}) { }) {
return ( return (
@@ -51,6 +53,22 @@ export default function SonucPage({
params.tur && params.tur in PUAN_TURLERI params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey) ? (params.tur as PuanTuruKey)
: "say"; : "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) { if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return ( return (
@@ -71,8 +89,14 @@ export default function SonucPage({
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında, {/* 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 sayfanın ana gövdesi; puan türü navbar'daki sıralama
rozetinden değiştirilir */} rozetinden değiştirilir. Sihirbaz seçimleri (il, devlet/
<SonucProgramTablosu sira={sira} tur={turKey} /> 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. {/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */} Havuzda içerik yoksa bileşen hiç render edilmez. */}

View File

@@ -7,11 +7,13 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { import {
ChevronDown, ChevronDown,
Rocket, Rocket,
Scale, Scale,
ShieldCheck, ShieldCheck,
X,
} from "lucide-react"; } from "lucide-react";
import type { import type {
DilimKey, DilimKey,
@@ -20,7 +22,7 @@ import type {
RankResultsListe, RankResultsListe,
} from "@/types/yokatlas"; } from "@/types/yokatlas";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk"; import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug"; import { bolumSayfaSlug, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug";
import { import {
ProgramSiraGecmisi, ProgramSiraGecmisi,
ProgramTabanTrendi, ProgramTabanTrendi,
@@ -94,21 +96,44 @@ export function ProgramTablosu({
sonuclar, sonuclar,
adaySira, adaySira,
tur, tur,
filtreIller,
filtreTip,
}: { }: {
sonuclar: RankResultsListe; sonuclar: RankResultsListe;
adaySira: number; adaySira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
filtreIller?: string[];
filtreTip?: "devlet" | "vakif";
}) { }) {
const router = useRouter();
const arama = useSearchParams();
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() => const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
varsayilanDilim(sonuclar), varsayilanDilim(sonuclar),
); );
const iller = useMemo(() => filtreIller ?? [], [filtreIller]);
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür değişince // Filtre çipini kaldırmak yalnızca URL'i günceller (tek doğruluk kaynağı
// (puan türü seçici aynı bileşeni yeniden render eder, remount etmez) // URL); localStorage'taki sihirbaz profiline DOKUNULMAZ — kullanıcı
// render sırasında sıfırlanır ki eski türün satırları yenisine karışmasın. // 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 [ekstra, setEkstra] = useState(BOS_EKSTRA);
const [yuklenen, setYuklenen] = useState<DilimKey | null>(null); const [yuklenen, setYuklenen] = useState<DilimKey | null>(null);
const veriAnahtari = `${adaySira}-${tur}`; const veriAnahtari = `${adaySira}-${tur}-${iller.join(",")}-${filtreTip ?? ""}`;
const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari); const [oncekiAnahtar, setOncekiAnahtar] = useState(veriAnahtari);
if (veriAnahtari !== oncekiAnahtar) { if (veriAnahtari !== oncekiAnahtar) {
setOncekiAnahtar(veriAnahtari); setOncekiAnahtar(veriAnahtari);
@@ -129,9 +154,15 @@ export function ProgramTablosu({
async function dahaFazlaYukle(dilim: DilimKey, offset: number) { async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim); setYuklenen(dilim);
try { try {
const res = await fetch( const q = new URLSearchParams({
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`, 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; if (!res.ok) return;
const veri = (await res.json()) as { programlar: ProgramListeSatiri[] }; const veri = (await res.json()) as { programlar: ProgramListeSatiri[] };
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir // Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
@@ -162,6 +193,39 @@ export function ProgramTablosu({
onPointerEnter={preloadTercihHaritasi} onPointerEnter={preloadTercihHaritasi}
onFocusCapture={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 <Tabs
value={aktifDilim} value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)} onValueChange={(v) => setAktifDilim(v as DilimKey)}

View File

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

View File

@@ -5,6 +5,7 @@ import type {
ProgramListeSatiri, ProgramListeSatiri,
PuanTuruKey, PuanTuruKey,
RankResultsListe, RankResultsListe,
UniturGrubu,
} from "@/types/yokatlas"; } from "@/types/yokatlas";
// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin. // 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, // /sonuc'un ham tablosu: sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır. // 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( export function siraylaProgramAra(
sira: number, sira: number,
tur: PuanTuruKey, tur: PuanTuruKey,
opts: { iller?: string[]; uniturGrubu?: UniturGrubu } = {},
): RankResultsListe { ): RankResultsListe {
const ham = searchByRank(sira, tur, { limitPerBucket: 20 }); const ham = searchByRank(sira, tur, {
limitPerBucket: 20,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
});
return { return {
hayal: ham.hayal.map(programOzetle), hayal: ham.hayal.map(programOzetle),
dengeli: ham.dengeli.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 }; 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( export function sonucHref(
profil: Pick<TercihProfili, "sira" | "tur">, profil: Pick<TercihProfili, "sira" | "tur"> & {
secimler?: SihirbazSecimleri;
},
ekstra?: Record<string, string>, ekstra?: Record<string, string>,
): string { ): string {
const q = new URLSearchParams({ const q = new URLSearchParams({
sira: String(profil.sira), sira: String(profil.sira),
tur: profil.tur, 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}`; return `/sonuc?${q}`;
} }