refactor: sihirbaz feature'ı — lib/sihirbaz bölündü, bileşenler taşındı (Faz 6)

- lib/sihirbaz.ts ikiye ayrıldı: sihirbaz-sabitler.ts (şema + doğrulayıcılar +
  event/storage sabitleri + sonucHref/secimOzeti, izomorfik) ve
  sihirbaz-profil.ts (localStorage oku/yaz/sil); 25 tüketicinin importu
  sembol bazında bölünerek güncellendi, localStorage anahtar/event değerleri
  birebir aynı
- sihirbaz-adimlar(+lazy), hero-form, cta-sira-form, il-secim-haritasi ve
  /sonuc'un sihirbaz-modal/sihirbaz-cagri-karti/sira-gerekli bileşenleri
  features/sihirbaz/components/ altına taşındı
- sihirbaz-queries.ts ve async SihirbazBolumu sarmalayıcısı /sonuc sayfa
  dönüşümüyle birlikte Faz 9'da gelecek (o zamana dek ölü kod olurlardı)
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 01:31:32 +03:00
parent baebc1fc80
commit 792e8c7346
36 changed files with 102 additions and 128 deletions

View File

@@ -17,7 +17,7 @@ import {
type Dilim,
type AdayProgram,
} from "../rapor-havuzu";
import { secimOzeti } from "../sihirbaz";
import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler";
import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk";
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu

View File

@@ -1,195 +0,0 @@
// Sonuç sayfasındaki sihirbaz modalının seçim şeması, doğrulaması ve prompt özeti.
// Kategoriler/iller sıralamaya göre dinamik üretilir (bkz. rankWindowFacets);
// öncelikler statik kalır (veriye bağlı değil, tercih niyeti).
import { KATEGORILER } from "./kategoriler";
import type { UniturGrubu } from "@/types/yokatlas";
export const ONCELIKLER = [
"İş garantisi",
"Yüksek maaş potansiyeli",
"Sevdiğim işi yapmak",
"Akademik kariyer",
"Yurtdışı imkânı",
"Prestijli üniversite",
"Kolay yerleşme",
] as const;
export const UNIVERSITE_TIPI_ETIKET: Record<UniturGrubu | "farketmez", string> = {
devlet: "Devlet",
vakif: "Vakıf (burslu olursa)",
farketmez: "Fark etmez",
};
export type UniversiteTipi = UniturGrubu | "farketmez";
export const PUAN_TURU_ETIKET: Record<string, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT",
};
// Girişsiz kullanıcının modalda yaptığı seçimler, giriş sonrası /sonuc'a
// dönene kadar burada bekler.
export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz";
// storage olayı yalnızca diğer sekmelerde tetiklenir; aynı sekmedeki
// aboneler (ör. navbar sıralama rozeti) bu olayla haberdar edilir.
export const PROFIL_DEGISTI_EVENT = "kolaytercih:profil-degisti";
// Arama modalındaki funnel CTA'sı /sonuc üzerindeyken sihirbaz
// orkestratörünü (SihirbazBolumu) bu olayla açar.
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
function profilDegistiginiYayinla(): void {
window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT));
}
export interface SihirbazSecimleri {
kategoriler: string[];
iller: string[];
universiteTipi: UniversiteTipi;
oncelikler: string[];
}
export interface TercihProfili {
sira: number;
tur: string;
secimler: SihirbazSecimleri;
}
const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"];
/**
* Client'tan gelen seçimleri güvene alır. Kategoriler KATEGORILER allowlist'ine
* göre süzülür; iller serbest string (yalnızca sanity — DB IN filtresi zaten
* eşleşmeyeni yok sayar), en fazla 5 il; öncelikler ONCELIKLER'e göre süzülür.
* En az bir kategori zorunlu.
*/
export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null {
if (typeof girdi !== "object" || girdi === null) return null;
const p = girdi as Record<string, unknown>;
const kategoriler = Array.isArray(p.kategoriler)
? [
...new Set(
p.kategoriler.filter(
(x): x is string =>
typeof x === "string" &&
(KATEGORILER as readonly string[]).includes(x),
),
),
]
: [];
if (kategoriler.length === 0) return null;
const iller = Array.isArray(p.iller)
? [
...new Set(
p.iller.filter(
(x): x is string => typeof x === "string" && x.trim().length > 0,
),
),
].slice(0, 5)
: [];
const universiteTipi =
typeof p.universiteTipi === "string" &&
UNI_TIPLERI.includes(p.universiteTipi as UniversiteTipi)
? (p.universiteTipi as UniversiteTipi)
: "farketmez";
const oncelikler = Array.isArray(p.oncelikler)
? [
...new Set(
p.oncelikler.filter(
(x): x is string =>
typeof x === "string" &&
(ONCELIKLER as readonly string[]).includes(x),
),
),
]
: [];
return { kategoriler, iller, universiteTipi, oncelikler };
}
/** localStorage'taki ortak sıra + sihirbaz profilini güvenle doğrular. */
export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null {
if (typeof girdi !== "object" || girdi === null) return null;
const p = girdi as Record<string, unknown>;
const sira = typeof p.sira === "number" ? p.sira : Number(p.sira);
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) return null;
if (typeof p.tur !== "string" || p.tur.trim().length === 0) return null;
const secimler = sihirbazDogrula(p.secimler);
if (!secimler) return null;
return { sira, tur: p.tur, secimler };
}
/** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */
export function tercihProfiliOku(): TercihProfili | null {
if (typeof window === "undefined") return null;
try {
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
if (!ham) return null;
return tercihProfiliDogrula(JSON.parse(ham));
} catch {
return null;
}
}
/** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */
export function tercihProfiliYaz(profil: TercihProfili): void {
if (typeof window === "undefined") return;
const dogru = tercihProfiliDogrula(profil);
if (!dogru) return;
try {
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
} catch {}
profilDegistiginiYayinla();
}
/** Profili siler (ör. Yapay Zeka listesi üretilip seçimler tüketildiğinde). */
export function tercihProfiliSil(): void {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
profilDegistiginiYayinla();
}
export function tercihProfiliTamamlandiMi(): boolean {
return tercihProfiliOku() !== null;
}
/** Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu kullanır. */
export function sonucHref(
profil: Pick<TercihProfili, "sira" | "tur">,
ekstra?: Record<string, string>,
): string {
const q = new URLSearchParams({
sira: String(profil.sira),
tur: profil.tur,
...ekstra,
});
return `/sonuc?${q}`;
}
/** Prompt'a enjekte edilecek Türkçe özet. */
export function secimOzeti(secimler: SihirbazSecimleri): string {
const satirlar = [
`- İlgi alanları: ${secimler.kategoriler.join(", ")}`,
];
if (secimler.iller.length > 0) {
satirlar.push(`- Tercih ettiği iller: ${secimler.iller.join(", ")}`);
}
satirlar.push(
`- Üniversite tipi tercihi: ${UNIVERSITE_TIPI_ETIKET[secimler.universiteTipi]}`,
);
if (secimler.oncelikler.length > 0) {
satirlar.push(`- Öncelikleri: ${secimler.oncelikler.join(", ")}`);
}
return satirlar.join("\n");
}

View File

@@ -1,6 +1,6 @@
import { kategoriEslesir } from "./kategoriler";
import { riskHesapla, type RiskSeviyesi } from "./risk";
import type { TercihProfili } from "./sihirbaz";
import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
export type DegerlendirilecekProgram = {
isim: string;