Yerel fonksiyon sihirbaz-sabitler.ts'e (sonucHref'in yanına) taşındı ve export edildi; `uret=1` parametre sırası sözleşmesinin yorumu da onunla gitti. Davranış birebir aynı — tadımlık CTA'sı da aynı hedefi kullanacağı için literal kopyanın dördüncü kez çoğalması engellendi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
// Sihirbazın seçim şeması, doğrulaması ve prompt özeti — izomorfik katman:
|
||
// hem client bileşenleri hem server action'ları/AI motoru buradan okur.
|
||
// localStorage'a dokunan profil yardımcıları sihirbaz-profil.ts'te yaşar.
|
||
// Kategoriler/iller sıralamaya göre dinamik üretilir (bkz. rankWindowFacets);
|
||
// öncelikler statik kalır (veriye bağlı değil, tercih niyeti).
|
||
|
||
import { KATEGORILER } from "@/lib/kategoriler";
|
||
import type { UniturGrubu } from "@/types/yokatlas";
|
||
|
||
export const ONCELIKLER = [
|
||
"İş bulma kolaylığı",
|
||
"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";
|
||
|
||
// Profilin sunucuya giden kopyası: /sonuc tablosu SSR'da bu çerezden
|
||
// filtrelenir, URL'de sira/il/tip paramı taşınmaz. localStorage client
|
||
// tarafındaki tek doğruluk kaynağı olmayı sürdürür; çerez onun aynasıdır
|
||
// (tercihProfiliYaz/Sil ikisini birlikte günceller).
|
||
export const PROFIL_CEREZ_ADI = "kolaytercih.profil";
|
||
|
||
// 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";
|
||
|
||
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
|
||
// Eski kayıtlı profiller: çip adı "İş garantisi" idi (vaat diliyle
|
||
// çeliştiği için değişti); seçim kaybolmasın diye yeni ada eşlenir.
|
||
.map(oncelikNormalize)
|
||
.filter(
|
||
(x): x is string =>
|
||
typeof x === "string" &&
|
||
(ONCELIKLER as readonly string[]).includes(x),
|
||
),
|
||
),
|
||
]
|
||
: [];
|
||
|
||
return { kategoriler, iller, universiteTipi, oncelikler };
|
||
}
|
||
|
||
/**
|
||
* Eski çip adı "İş garantisi" → "İş bulma kolaylığı". Kayıtlı profil ve eski
|
||
* rapor parametreleri eski adı taşıyabilir; karşılaştırma/gösterim öncesi
|
||
* yeni ada eşlenir ki ad değişikliği "tercihlerin değişmiş" diye okunmasın.
|
||
*/
|
||
export function oncelikNormalize<T>(x: T): T | string {
|
||
return x === "İş garantisi" ? "İş bulma kolaylığı" : x;
|
||
}
|
||
|
||
/** 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 };
|
||
}
|
||
|
||
/** /sonuc çerezindeki ham değeri güvenle profile çözer (SSR tarafı). */
|
||
export function cerezdenTercihProfili(
|
||
ham: string | undefined | null,
|
||
): TercihProfili | null {
|
||
if (!ham) return null;
|
||
try {
|
||
return tercihProfiliDogrula(JSON.parse(decodeURIComponent(ham)));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sonuç sayfası URL'i. Profil verisi (sıra/tür/il/tip) URL'de taşınmaz —
|
||
* /sonuc'un ham tablosu SSR'da PROFIL_CEREZ_ADI çerezinden filtrelenir.
|
||
* `ekstra` yalnızca tek seferlik akış bayrakları içindir (sihirbaz=1, hazir=1).
|
||
*/
|
||
export function sonucHref(ekstra?: Record<string, string>): string {
|
||
const girdiler = Object.entries(ekstra ?? {});
|
||
if (girdiler.length === 0) return "/sonuc";
|
||
return `/sonuc?${new URLSearchParams(girdiler)}`;
|
||
}
|
||
|
||
/**
|
||
* Üretimin koştuğu /listem URL'i. `kaynak` denemenin hangi yüzeyden
|
||
* başladığını ölçer (izinli değerler:
|
||
* rapor/components/uretim-basladi-olayi.tsx — yeni yüzeyi önce oraya ekle).
|
||
*
|
||
* `uret=1` HER ZAMAN ilk parametre kalmalı: giris-karti.tsx funnel metnini
|
||
* `callbackURL.startsWith("/listem?uret=1")` ile seçiyor, sıra bozulursa
|
||
* girişteki "Seçimlerin hazır" metni sessizce kaybolur. Bu sözleşmenin
|
||
* literal kopyası çoğalmasın diye tek kaynak burası.
|
||
*/
|
||
export function listemUretHref(kaynak: string): string {
|
||
return `/listem?uret=1&kaynak=${kaynak}`;
|
||
}
|
||
|
||
/** 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");
|
||
}
|