diff --git a/data/app.db b/data/app.db index 691b532..6fd4f28 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/src/features/kullanici/components/giris-karti.tsx b/src/features/kullanici/components/giris-karti.tsx index e93f716..6aa555f 100644 --- a/src/features/kullanici/components/giris-karti.tsx +++ b/src/features/kullanici/components/giris-karti.tsx @@ -22,8 +22,10 @@ export async function GirisKarti({ const session = await getSession(); if (session) redirect(callbackURL); - // Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı - const funnel = callbackURL.startsWith("/sonuc"); + // Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı. /listem?uret=1 + // callback'i de aynı vaadi taşır (girişten dönüşte üretim orada başlar). + const funnel = + callbackURL.startsWith("/sonuc") || callbackURL.startsWith("/listem?uret=1"); return ( <> diff --git a/src/features/liste/hooks/use-tercih-profili.ts b/src/features/liste/hooks/use-tercih-profili.ts index 4284edb..d3e5a3f 100644 --- a/src/features/liste/hooks/use-tercih-profili.ts +++ b/src/features/liste/hooks/use-tercih-profili.ts @@ -273,8 +273,15 @@ export function profilTamamlandi( // Düzenleme /sonuc'a götürür: tablo profil çerezinden SSR'lanır // (sira/tur/il/tip) ve tercihProfiliYaz çerezi az önce güncelledi. URL // değişmediği için sunucu tablosunun tazelenmesi kapı bileşenindeki - // router.refresh ile garanti edilir. - if (duzenle) return sonucHref(); + // router.refresh ile garanti edilir. Tek istisna /listem: kullanıcı + // listesinin başındayken tercihlerini düzenliyorsa listesinden koparılmaz, + // aynı sayfaya döner ve "tercihlerin değişti" uyarısı orada karşılar. + if (duzenle) { + return typeof window !== "undefined" && + window.location.pathname === "/listem" + ? "/listem" + : sonucHref(); + } return null; } diff --git a/src/features/rapor/components/liste-uretici.tsx b/src/features/rapor/components/liste-uretici.tsx index 591b473..b189758 100644 --- a/src/features/rapor/components/liste-uretici.tsx +++ b/src/features/rapor/components/liste-uretici.tsx @@ -2,8 +2,14 @@ // /listem üretim gövdesi: sihirbazın kaydettiği bekleyen seçimlerle Yapay // Zeka listesi burada kurulur (sonuç sayfası yalnızca buraya yönlendirir). -// Başarıda profil tüketilir ve sayfa yenilenir; kredi-yetersiz ve hata -// durumları da bu bileşende karşılanır. +// Kredi-yetersiz ve hata durumları da bu bileşende karşılanır. +// +// Profil üretimden sonra SİLİNMEZ: kullanıcının tercih profili sitenin geri +// kalanının da kaynağı (/sonuc tablo filtresi, navbar sıralama rozeti, "+" +// kapısı) ve /listem'deki "tercihlerin değişti" karşılaştırmasının temeli +// (bkz. tercih-farki.ts). Tekrar üretim riski yok: rapor varken bu bileşen +// hiç render edilmez (bkz. listem-icerik.tsx), yani ?uret=1 ile dönmek ya da +// sayfayı yenilemek ikinci bir harcama başlatmaz. import { useEffect, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; @@ -14,14 +20,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { listeOlustur } from "@/features/rapor/rapor-actions"; import { olay, siraKovasi } from "@/lib/analitik"; import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; -import { tercihProfiliOku, tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil"; - -const URETIM_MESAJLARI = [ - "Sıralamana uygun programlar taranıyor…", - "Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…", - "Her tercih için risk ve trend analizi yazılıyor…", - "Son rötuşlar yapılıyor, neredeyse hazır…", -]; +import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil"; +import { useUretimMesaji } from "./uretim-mesaji"; type Durum = | { ad: "uretiliyor" } @@ -31,9 +31,14 @@ type Durum = export function ListeUretici({ hasPaket, + sunucuProfil = null, bosDurum, }: { hasPaket: boolean; + /** Profil çerezinden SSR'da çözülen yedek profil: localStorage silinmiş ya + * da erişilemez olsa bile (gizli sekme, temizlenmiş depo) üretim başlasın. + * localStorage yine de birincil kaynak — çerez onun aynası. */ + sunucuProfil?: TercihProfili | null; /** Storage'da bekleyen seçim yoksa gösterilecek boş-liste durumu */ bosDurum: ReactNode; }) { @@ -63,10 +68,9 @@ export function ListeUretici({ tur: profil.tur, sira_kovasi: siraKovasi(profil.sira), }); - tercihProfiliSil(); - // ?uret=1 URL'de kalmasın: yenileme/geri dönüş üretimi tekrar - // tetiklemesin. refresh sunucudaki raporu (ve header'daki kredi - // pill'ini) ekrana getirir. + // ?uret=1 URL'de kalmasın: geri dönüşte anlamsız bayrak taşınmasın. + // refresh sunucudaki raporu (ve header'daki kredi pill'ini) ekrana + // getirir. window.history.replaceState(null, "", "/listem"); router.refresh(); return; @@ -91,8 +95,9 @@ export function ListeUretici({ useEffect(() => { if (basladi.current) return; basladi.current = true; - // Bekleyen seçimler yalnızca client storage'da; mount sonrası okunur. - const kayitli = tercihProfiliOku(); + // Bekleyen seçimler client storage'da; mount sonrası okunur. Depo boşsa + // (temizlenmiş/erişilemez) sunucunun çerezden çözdüğü profile düşülür. + const kayitli = tercihProfiliOku() ?? sunucuProfil; if (!kayitli) { // eslint-disable-next-line react-hooks/set-state-in-effect setDurum({ ad: "profil-yok" }); @@ -165,14 +170,7 @@ export function ListeUretici({ } function UretimGovdesi() { - const [mesajIdx, setMesajIdx] = useState(0); - - useEffect(() => { - const interval = setInterval(() => { - setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1)); - }, 7000); - return () => clearInterval(interval); - }, []); + const mesaj = useUretimMesaji(); return (

Listen hazırlanıyor

-

- {URETIM_MESAJLARI[mesajIdx]} -

+

{mesaj}

Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.

diff --git a/src/features/rapor/components/listem-bos-cta.tsx b/src/features/rapor/components/listem-bos-cta.tsx index d43170c..b4ff8c0 100644 --- a/src/features/rapor/components/listem-bos-cta.tsx +++ b/src/features/rapor/components/listem-bos-cta.tsx @@ -5,12 +5,11 @@ import { ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PagePixelDivider } from "@/components/pixel-decor"; import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili"; -import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler"; /** - * /listem boş hâlinin metni + CTA'sı: sıralamasını zaten girmiş adaya - * "sıralamanı gir" demek yerine sihirbazı açık hâlde sonuç sayfasına - * götürür; profil yoksa ana sayfadaki sıralama formuna yönlendirir. + * /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı + * /sonuc üzerinden dolaştırmadan doğrudan üretime (?uret=1) alır; profil + * yoksa ana sayfadaki sıralama formuna yönlendirir. */ export function ListemBosCta() { const profil = useTercihProfili(); @@ -28,7 +27,7 @@ export function ListemBosCta() { size="lg" className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600" > - + {profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"} diff --git a/src/features/rapor/components/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index 4f17e93..bf7c2ea 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -14,6 +14,7 @@ import { Lock, PencilLine, ShieldCheck, + SlidersHorizontal, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RaporListesi } from "./rapor-listesi"; @@ -24,6 +25,7 @@ import { pinleriTuret } from "@/lib/harita-pinler"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client"; import { RevizyonKutusu } from "./revizyon-kutusu"; +import { profilDuzenlemeyiAc } from "@/features/liste/hooks/use-tercih-profili"; export function ListemGovde({ rapor, @@ -122,32 +124,47 @@ export function ListemGovde({ ? "Yapay Zeka tercih listen" : "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."} - {hasPaket ? ( -
- - -
- ) : null} +
+ {/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte + TercihProfiliKapisi) profili günceller, /listem'e döner ve + değişiklik varsa "tercihlerin değişti" uyarısı karşılar. */} + + {hasPaket ? ( + <> + + + + ) : null} +
) : ( @@ -100,6 +112,16 @@ export async function ListemIcerik({ PDF artık açık. ) : null} + {/* Tercihler liste kurulduktan sonra değiştiyse (sıra, alan, il, tip, + öncelik) uyarı burada karşılar ve listeyi yeni tercihlerle yeniden + kurmayı önerir — üretimle aynı kredi kapısından geçerek. Fark + hesabı client'ta yapılır: güncel profil localStorage'ta yaşar. */} + {/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında (bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */} ({ ad: "bekliyor" }); + // Profil client'ta çözüldüğü için bu bileşen SSR'da zaten hiçbir şey + // render etmiyor; gizleme kaydı ilk render'da güvenle okunabilir. + const [gizlenenImza, setGizlenenImza] = useState( + gizlenenImzayiOku, + ); + // Aynı deneme için sabit requestId: cevabı kaybolan istekte retry çifte + // harcamaz (bkz. liste-uretici.tsx ve spendCredits idempotency'si). + const requestIdRef = useRef(null); + + const farklar = useMemo( + () => (profil ? tercihFarki(params, profil) : []), + [params, profil], + ); + const satirlar = useMemo( + () => (profil ? farkSatirlari(params, profil, farklar) : []), + [params, profil, farklar], + ); + + async function guncelle() { + if (!profil) return; + if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); + setDurum({ ad: "uretiliyor" }); + + try { + const sonuc = await listeOlustur({ + sira: profil.sira, + tur: profil.tur, + secimler: profil.secimler, + requestId: requestIdRef.current, + }); + requestIdRef.current = null; + if (sonuc.ok) { + olay("liste_guncellendi", { + tur: profil.tur, + sira_kovasi: siraKovasi(profil.sira), + degisen_alan: farklar.length, + }); + toast.success("Listen yeni tercihlerine göre yeniden kuruldu."); + // Sunucu params'ı tazelenince fark kalmaz ve uyarı kendiliğinden + // kaybolur; refresh dönene kadar "güncellendi" hâli görünür. + setDurum({ ad: "tamamlandi" }); + router.refresh(); + return; + } + if (sonuc.code === "KREDI" || sonuc.code === "PAKET") { + olay("kredi_bitti"); + setDurum({ ad: "kredi-yetersiz" }); + } else if (sonuc.code === "AUTH") { + router.push(`/giris?callback=${encodeURIComponent("/listem")}`); + } else { + setDurum({ ad: "hata", mesaj: sonuc.error }); + } + } catch { + setDurum({ + ad: "hata", + mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?", + }); + } + } + + function gizle() { + if (!profil) return; + const imza = profilImzasi(profil); + try { + localStorage.setItem(GIZLE_ANAHTARI, imza); + } catch {} + setGizlenenImza(imza); + } + + if (!profil || farklar.length === 0) return null; + if (durum.ad === "tamamlandi") { + return ( +
+ + Listen yeni tercihlerine göre güncellendi. +
+ ); + } + // Kapatılmış değişiklik: kullanıcı aynı tercihlerle bir daha rahatsız + // edilmez. Üretim/hata hâlleri kapatmadan bağımsız görünür kalır. + if (durum.ad === "bekliyor" && gizlenenImza === profilImzasi(profil)) { + return null; + } + + const krediYeter = kredi >= raporKredi; + + return ( +
+
+ + + +
+

+ Tercihlerin değişti +

+

+ Aşağıdaki liste eski tercihlerinle kuruldu; {farkCumlesi(farklar)}{" "} + o zamandan beri değişti. Yeni tercihlerine göre yeniden kurabilirsin + — yeni liste bunun yerine geçer ve {raporKredi} kredi düşer. +

+ +
+ {satirlar.map((satir) => ( +
+
+ {satir.etiket} +
+
+ {satir.eski} + + → + + {satir.yeni} +
+
+ ))} +
+ + {durum.ad === "uretiliyor" ? ( + + ) : durum.ad === "kredi-yetersiz" ? ( + + ) : ( +
+ {durum.ad === "hata" ? ( + <> +

{durum.mesaj}

+ + + ) : krediYeter ? ( + + ) : ( + + )} + + {!krediYeter && durum.ad !== "hata" ? ( +

+ Güncelleme {raporKredi} kredi; bakiyen {kredi} kredi. +

+ ) : null} +
+ )} +
+ + {durum.ad === "bekliyor" ? ( + + ) : null} +
+
+ ); +} + +function UretimSatiri() { + const mesaj = useUretimMesaji(); + return ( +
+

+ + Listen yeni tercihlerine göre kuruluyor… +

+

{mesaj}

+

+ Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma. +

+
+ ); +} + +function KrediYetersiz({ + hasPaket, + raporKredi, +}: { + hasPaket: boolean; + raporKredi: number; +}) { + return ( +
+

+ {hasPaket + ? `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin.` + : `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Tercih Dönemi Paketi'yle kredilerin, tam liste, PDF ve revizyon hakları birlikte gelir.`} +

+ +
+ ); +} diff --git a/src/features/rapor/components/uretim-mesaji.ts b/src/features/rapor/components/uretim-mesaji.ts new file mode 100644 index 0000000..11d8715 --- /dev/null +++ b/src/features/rapor/components/uretim-mesaji.ts @@ -0,0 +1,31 @@ +"use client"; + +// Liste üretiminin bekleme metinleri tek kaynakta: ilk kurulum (ListeUretici) +// ve tercih değişikliği sonrası yeniden kurulum (TercihDegistiUyarisi) aynı +// dili ve ritmi paylaşsın diye. + +import { useEffect, useState } from "react"; + +export const URETIM_MESAJLARI = [ + "Sıralamana uygun programlar taranıyor…", + "Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…", + "Her tercih için risk ve trend analizi yazılıyor…", + "Son rötuşlar yapılıyor, neredeyse hazır…", +] as const; + +const ARALIK_MS = 7000; + +/** Sırayla ilerleyen, sonuncuda duran bekleme metni. Yalnızca üretim + * sürerken monte edilir — sayaç mount ile başlar. */ +export function useUretimMesaji(): string { + const [idx, setIdx] = useState(0); + + useEffect(() => { + const zamanlayici = setInterval(() => { + setIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1)); + }, ARALIK_MS); + return () => clearInterval(zamanlayici); + }, []); + + return URETIM_MESAJLARI[idx]; +} diff --git a/src/features/rapor/tercih-farki.ts b/src/features/rapor/tercih-farki.ts new file mode 100644 index 0000000..12897ef --- /dev/null +++ b/src/features/rapor/tercih-farki.ts @@ -0,0 +1,134 @@ +// Kayıtlı listenin üretildiği tercihler (rapor params) ile kullanıcının şu +// anki tercih profilini karşılaştırır. /listem'deki "tercihlerin değişti" +// uyarısı ve güncelleme CTA'sı bu farktan doğar. +// +// İzomorfik: yalnızca tip ve etiket sabitlerine bağlı — hem client bileşeni +// hem sunucu tarafı tüketebilir. +// +// Eski akışla üretilmiş raporlarda sihirbaz alanları (kategoriler/iller/ +// universiteTipi/oncelikler) hiç yok. Olmayan alan "değişmiş" sayılmaz: +// kıyaslanacak bir değer yokken kullanıcıyı 3 kredilik güncellemeye +// çağırmak dürüst olmaz. Sıralama ve puan türü her raporda dolu olduğundan +// onlar her zaman kıyaslanır. + +import { + PUAN_TURU_ETIKET, + UNIVERSITE_TIPI_ETIKET, + type TercihProfili, +} from "@/features/sihirbaz/sihirbaz-sabitler"; +import type { RaporParams } from "./types/rapor"; + +export type FarkAlani = + | "sira" + | "tur" + | "kategoriler" + | "iller" + | "universiteTipi" + | "oncelikler"; + +export const FARK_ETIKET: Record = { + sira: "başarı sıralaman", + tur: "puan türün", + kategoriler: "ilgi alanların", + iller: "şehir tercihlerin", + universiteTipi: "devlet/vakıf tercihin", + oncelikler: "önceliklerin", +}; + +/** Sıra/tür/seçim üçlüsünün kararlı imzası — uyarının "bu değişikliği + * gizle" kaydı bu imzaya bağlanır (tercih yine değişirse uyarı geri gelir). */ +export function profilImzasi(profil: TercihProfili): string { + const s = profil.secimler; + return JSON.stringify([ + Math.round(profil.sira), + profil.tur, + [...s.kategoriler].sort(), + [...s.iller].sort(), + s.universiteTipi, + [...s.oncelikler].sort(), + ]); +} + +// Sıra bağımsız küme kıyası: sihirbaz seçimleri tekrarsız olarak doğrulanır +// (bkz. sihirbazDogrula), aynı seçimlerin farklı sırayla kaydedilmesi +// "değişiklik" değildir. +function kumeFarkli( + eski: readonly string[] | undefined, + yeni: readonly string[], +): boolean { + if (!eski) return false; + if (eski.length !== yeni.length) return true; + const kume = new Set(eski); + return yeni.some((x) => !kume.has(x)); +} + +function liste(degerler: readonly string[] | undefined): string { + return degerler && degerler.length > 0 ? degerler.join(", ") : "—"; +} + +export function tercihFarki( + params: RaporParams, + profil: TercihProfili, +): FarkAlani[] { + const farklar: FarkAlani[] = []; + const s = profil.secimler; + + if (Math.round(params.sira) !== Math.round(profil.sira)) farklar.push("sira"); + if (params.tur !== profil.tur) farklar.push("tur"); + if (kumeFarkli(params.kategoriler, s.kategoriler)) farklar.push("kategoriler"); + if (kumeFarkli(params.iller, s.iller)) farklar.push("iller"); + if ( + params.universiteTipi !== undefined && + params.universiteTipi !== s.universiteTipi + ) { + farklar.push("universiteTipi"); + } + if (kumeFarkli(params.oncelikler, s.oncelikler)) farklar.push("oncelikler"); + + return farklar; +} + +/** "başarı sıralaman ve ilgi alanların" — uyarı cümlesinin ortası. */ +export function farkCumlesi(farklar: FarkAlani[]): string { + const etiketler = farklar.map((f) => FARK_ETIKET[f]); + if (etiketler.length <= 1) return etiketler[0] ?? ""; + return `${etiketler.slice(0, -1).join(", ")} ve ${etiketler[etiketler.length - 1]}`; +} + +export type FarkSatiri = { alan: FarkAlani; etiket: string; eski: string; yeni: string }; + +/** Değişen her alanın eski → yeni değeri; uyarıda tek tek gösterilir. */ +export function farkSatirlari( + params: RaporParams, + profil: TercihProfili, + farklar: FarkAlani[], +): FarkSatiri[] { + const s = profil.secimler; + const sayi = (n: number) => Math.round(n).toLocaleString("tr-TR"); + + return farklar.map((alan) => { + const ortak = { alan, etiket: FARK_ETIKET[alan] }; + switch (alan) { + case "sira": + return { ...ortak, eski: `${sayi(params.sira)}.`, yeni: `${sayi(profil.sira)}.` }; + case "tur": + return { + ...ortak, + eski: PUAN_TURU_ETIKET[params.tur] ?? params.tur, + yeni: PUAN_TURU_ETIKET[profil.tur] ?? profil.tur, + }; + case "kategoriler": + return { ...ortak, eski: liste(params.kategoriler), yeni: liste(s.kategoriler) }; + case "iller": + return { ...ortak, eski: liste(params.iller), yeni: liste(s.iller) }; + case "universiteTipi": + return { + ...ortak, + eski: UNIVERSITE_TIPI_ETIKET[params.universiteTipi ?? "farketmez"], + yeni: UNIVERSITE_TIPI_ETIKET[s.universiteTipi], + }; + case "oncelikler": + return { ...ortak, eski: liste(params.oncelikler), yeni: liste(s.oncelikler) }; + } + }); +} diff --git a/src/lib/analitik.ts b/src/lib/analitik.ts index c81a107..2ed81cb 100644 --- a/src/lib/analitik.ts +++ b/src/lib/analitik.ts @@ -17,6 +17,7 @@ export type OlayAdi = | "sihirbaz_tamamlandi" | "giris_denendi" | "liste_olusturuldu" + | "liste_guncellendi" | "liste_revize" | "program_eklendi" | "netler_goruntulendi" diff --git a/src/lib/harita-pinler.ts b/src/lib/harita-pinler.ts index be6f13d..84f6c37 100644 --- a/src/lib/harita-pinler.ts +++ b/src/lib/harita-pinler.ts @@ -32,8 +32,9 @@ export type YurtdisiPin = { /** * Yurtdışı tercihini şehir pinine ekler; şehir çözülemezse false (harita - * dışı). Kilitli satırlarda universite null verilir — pin şehir bazlı - * olduğundan üniversite kimliği zaten görünmez. + * dışı). Kilitli satırlarda da üniversite adı verilebilir: ad yalnızca şehri + * çözmek için okunur (KKTC satırlarının çoğunda şehir adın parantez ekinde), + * üretilen pin yalnızca ülke/şehir/sayı taşır — üniversite kimliği görünmez. */ export function yurtdisiPinKaydet( gruplar: Map, @@ -92,7 +93,8 @@ export function pinleriTuret( if (kilitli) { const konum = ilKonum(p.il); if (!konum) { - if (!yurtdisiPinKaydet(yurtdisi, null, p.il, t.sira, risk)) + // Şehir çözümü için ad okunur, pine yazılmaz (bkz. yurtdisiPinKaydet) + if (!yurtdisiPinKaydet(yurtdisi, p.universite, p.il, t.sira, risk)) haritaDisi += 1; return; } diff --git a/src/lib/harita-ulkeler.ts b/src/lib/harita-ulkeler.ts index 6fb5154..687d2b6 100644 --- a/src/lib/harita-ulkeler.ts +++ b/src/lib/harita-ulkeler.ts @@ -81,6 +81,23 @@ const UNI_DUZELTME: Record = { /** il değeri Türkiye dışını mı gösteriyor? ("KIBRIS" genel değeri dahil) */ const YURTDISI_IL = new Set([...Object.keys(SEHIRLER), "KIBRIS"]); +/** + * Ad sonundaki "(KKTC-LEFKOŞA)" ekinden şehir anahtarı. YÖK Atlas'ta 100'den + * fazla KKTC satırının il alanı şehir değil genel "KIBRIS"tır (Doğu Akdeniz, + * Yakın Doğu, Lefke Avrupa, Uluslararası Final, Kıbrıs Sağlık ve Toplum + * Bilimleri); şehir yalnızca üniversite adının parantezinde durur. Bu ek + * okunmazsa o programlar hiçbir haritaya düşmez, "haritada gösterilemiyor" + * sayısına yazılır. + */ +function ektenSehir(universite: string): SehirAnahtari | null { + const ek = universite.match(/\(([^)]*)\)\s*$/)?.[1]; + if (!ek) return null; + // "KKTC-GAZİMAĞUSA" → "GAZİMAĞUSA"; ülke öneki olmayan "(ANKARA)" gibi + // ekler SEHIRLER'de karşılık bulmadığından kendiliğinden elenir. + const sehir = ek.split("-").pop()!.trim().toLocaleUpperCase("tr-TR"); + return sehir in SEHIRLER ? (sehir as SehirAnahtari) : null; +} + /** Üniversite adı + il'den şehir anahtarı; şehri bilinmeyen KKTC'de ülke. */ type Cozum = { anahtar: SehirAnahtari } | { ulke: UlkeKodu } | null; @@ -96,7 +113,10 @@ function coz(universite: string, il: string | null): Cozum { } if (!il || !YURTDISI_IL.has(il)) return null; if (il in SEHIRLER) return { anahtar: il as SehirAnahtari }; - return { ulke: "kktc" }; // il "KIBRIS": ülke bilinir, şehir bilinmez + // il "KIBRIS": ülke bilinir, şehir adın parantez ekinden okunur; + // o da yoksa ülke seviyesinde kalır (şehir pini çizilemez) + const ekSehir = ektenSehir(universite); + return ekSehir ? { anahtar: ekSehir } : { ulke: "kktc" }; } export type UlkeKonum = {