feat: tadımlık havuzuna üniversite tipi boyutu

Tadımlık satırı batch üretildiği için sihirbazdaki devlet/vakıf seçimini
bilmiyor, vakıf seçen kullanıcıya devlet örneği gösteriyordu. Havuz
anahtarı kova×tür×kategori×tip'e genişledi (migration 0001; mevcut
satırlar tip='genel'). Üretim scripti tip varyantlarını uniturGrubu
filtresiyle seçer; tadimlikSec tip verildiğinde kategori→genel fallback'i
korur ama tip fallback'i YAPMAZ — uygun satır yoksa bileşen gizlenir,
yanlış tipte örnek asla gösterilmez. Client profildeki tipi de gönderir ve
SSR "genel" satırı tipe uymuyorsa fetch bitene kadar gizler.

Not: prod app.db volume'de — `pnpm tadimlik` prod DB'ye karşı ayrıca
koşulmalı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-09 00:12:25 +03:00
parent 0c6c9da56c
commit 14f9b622b0
8 changed files with 1077 additions and 84 deletions

View File

@@ -27,10 +27,17 @@ import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import { PROFIL_DEGISTI_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
import type { TadimlikSatir, TadimlikTip } from "@/lib/tadimlik-havuzu";
const GENEL = "genel";
/** Program tipi ile sihirbaz tercihi eşleşiyor mu (uniturFiltreSql semantiği). */
function tipeUyar(program: TadimlikSatir["program"], tip: TadimlikTip): boolean {
if (tip === "genel") return true;
if (tip === "devlet") return program.unitur === "DEVLET";
return program.unitur != null && program.unitur !== "DEVLET";
}
export function TadimlikSatiri({
sira,
tur,
@@ -42,29 +49,46 @@ export function TadimlikSatiri({
ilk: TadimlikSatir | null;
}) {
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
const yuklenenSlug = useRef(GENEL);
const yuklenenAnahtar = useRef(`${GENEL}|${GENEL}`);
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
// Sihirbaz kategorisi ve devlet/vakıf tercihi yalnızca localStorage'da
// yaşar; mount'ta ve profil değiştikçe okunur, seçime özel satır sunucudan
// çekilir. Tip tercihi varsa yanlış tipte örnek bir an bile gösterilmez.
useEffect(() => {
const guncelle = () => {
const profil = tercihProfiliOku();
const kategori = profil?.secimler.kategoriler[0];
const slug = kategori ? turkishSlugify(kategori) : GENEL;
if (slug === yuklenenSlug.current) return;
yuklenenSlug.current = slug;
if (slug === GENEL) {
const tipTercihi = profil?.secimler.universiteTipi;
const tip: TadimlikTip =
tipTercihi === "devlet" || tipTercihi === "vakif"
? tipTercihi
: GENEL;
const anahtar = `${slug}|${tip}`;
if (anahtar === yuklenenAnahtar.current) return;
yuklenenAnahtar.current = anahtar;
if (slug === GENEL && tip === GENEL) {
setSatir(ilk);
return;
}
fetch(
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
)
// SSR "genel" satırı seçilen tipe uymuyorsa fetch bitene kadar gizle
setSatir((mevcut) =>
mevcut && !tipeUyar(mevcut.program, tip) ? null : mevcut,
);
const q = new URLSearchParams({ sira: String(sira), tur });
if (slug !== GENEL) q.set("kategori", slug);
if (tip !== GENEL) q.set("tip", tip);
fetch(`/api/tadimlik?${q}`)
.then((yanit) => (yanit.ok ? yanit.json() : null))
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
if (yuklenenSlug.current !== slug) return;
if (veri?.tadimlik) setSatir(veri.tadimlik);
// Yarış koruması: bu arada başka seçim yüklendiyse dokunma
if (yuklenenAnahtar.current !== anahtar) return;
if (veri?.tadimlik) {
setSatir(veri.tadimlik);
} else if (tip !== GENEL) {
// Uygun tipte satır yok: yanlış tip göstermektense gizle
setSatir(null);
}
})
.catch(() => {});
};