From 437e2bc96aa90695f561bace52dd124524fdf081 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Sun, 9 Aug 2026 04:15:53 +0300 Subject: [PATCH] fix: update callback handling and user profile management in list components Enhanced the handling of user profiles and callback URLs in various components. Updated the `GirisKarti` to support multiple callback scenarios, ensuring seamless user experience. Improved the `ListeUretici` and `ListemIcerik` components to manage profile data more effectively, including handling cases where the profile is stored in cookies. Additionally, refined the logic for redirecting users based on their profile status, ensuring they are directed to the appropriate pages without losing context. Updated the database file to reflect these changes. --- data/app.db | Bin 577536 -> 585728 bytes .../kullanici/components/giris-karti.tsx | 6 +- .../liste/hooks/use-tercih-profili.ts | 11 +- .../rapor/components/liste-uretici.tsx | 50 ++- .../rapor/components/listem-bos-cta.tsx | 9 +- .../rapor/components/listem-govde.tsx | 69 ++-- .../rapor/components/listem-icerik.tsx | 30 +- .../components/tercih-degisti-uyarisi.tsx | 302 ++++++++++++++++++ .../rapor/components/uretim-mesaji.ts | 31 ++ src/features/rapor/tercih-farki.ts | 134 ++++++++ src/lib/analitik.ts | 1 + src/lib/harita-pinler.ts | 8 +- src/lib/harita-ulkeler.ts | 22 +- 13 files changed, 603 insertions(+), 70 deletions(-) create mode 100644 src/features/rapor/components/tercih-degisti-uyarisi.tsx create mode 100644 src/features/rapor/components/uretim-mesaji.ts create mode 100644 src/features/rapor/tercih-farki.ts diff --git a/data/app.db b/data/app.db index 691b532c7f3a962a97dfaf4667e0e6f1b681447d..6fd4f283ded935770649880daa1e61bac5708a12 100644 GIT binary patch delta 9501 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 delta 3123 zcma)8d2HL}6&EF1lB3ve>KskdB($6~O=~5jD3MZ=9kOK0l1$xqmN}F}9Xv#dqO4=f zZ0BfpAgdi=Bd~UelN246t%W_e5!R(>fT+N>Zf%BPK#O)r;ch|N{m~S|wqjd~QEm`) zz<`Xz0|IGG= zN4c;vbo8WBcjgSP+(6)4iBsm*4;~a|68VX+U_-OTamHmfqh`#3IpN7l#BOjByup&P zdo8i4aLJdaX^%Y?94`t1V{AHF_H%x`6rP?!Tob&j6fan`0&6q>vp*c)8;0dYkKXYV`HPy zTq%Mn=ncL$Fi}R_~}gP1~@Ny-54a zr5NQkC$g65gdvhoQGB>Al&z)WWZA)5SkmuLCps_+n#7(m}u(XOySdv5TqAz)Z~-4#2+fSjELfO=-IeBf@Eu zm$sRG4x5>X!~5+~H43$b>ml zag>>CFwPL1UnGb+Mu>4M#vxoRN*nAn5)gz8iA*^Rq?IS*%^FyOdd0K#=ULTg58u+vo{}r%eQ=S$+MUGRHN!z zOS5QQ8C`rp-Gbr0E%GP3SDt>3w)X0(3SCume&Am+#t!Am)@Yag9vOfAux(! z`VoDlAdvbeP-%9jYNzzdMNqd=e1A94d-oHwNm^P2H%i~$soL0G-Gt5^|1zkH5XTQ5 zpF^}Dh@w>1NM$Sr3L$ZW$%vo^(gbG>SvZ?U(l}-zk!yI}dnF*1N&T@rhA(ADFo#kSNWC~E@+7+fkF;I$p z?Hnn{Z#&D)&N7stI_D^XrHYw?z{$&nYd+$`}QD~Ggl^V+3QsEaEHR&M~h zTXmqD0ATKo!>y%1A8tLhx~V_hrNcpwKn}ihj%;07wY4ta+@nOz-C(5mUIulo;`@iB z`Io@3H2(|mu;g0=V*A;`>powP1#{^z>hMbgfNn z177Ue(*LPK-T$^?fEzeF(C&YGV0d7nd#`M`{Y<~~;(oxrZ{S_Up8G{Guv8THH($)21`xye@ z^uWvQbtPb_-*em!XbIv31A?HtEHbGC*K`JBw5OQJiLejl0yTThg-uVo9g)OTxXPq$ zkua5KIa9oWW=oDF$2MyyJn9khxb$fq7@olo~fXio}4V^Y8;a-a^A!LEiErxK|Sq0+vk=znBA zu6;`u_eS;lQz+0IA)XvOVFy9!-5bDe$$J&p*S_;Bpb1EQZvfi$$cSF73No(>*^tKH z1a`KbUER82L?0qM=h)6NBiC@avs`LfNHn;dFqDM@seg8GrxFITaLZ48;A zxB>$)vUD4ipg(f#7+twqrE5pq?U~nsIdHvPiocb4Y(KH%^E@P(hp0Od1GHU^iq9`~ zDb4~)GJN?upt?FJ?^gh_8FidWt)Y5J#p|u1qc64pa9n#x+1~msZCKeJo>Q(%6(rf2fO5`Z1VNw2L)BpTNn$oMc>YTPo zxsS{LB({V!$15E@2!^A$G?Y_A`;2l=w^&Ek-k6RY8~i3+c{O@6xwFf3ugr94jj2^; zimx&KigJ?_45%1sv7%(8+eT$hnh&bZN=vV**MU~djC5i5^dsHJdw9<+DodODIkWwf_b-o}5Z-h-@FkW#5K1-$N=;>e!UK z*O_4h0>ey&Z%QBQ)SET(jO-31X$Vjaj)Wh9kxi1Ti9Jcw}v9q`T+HF<|#6YxzAClVjX4bWaIe 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 = {