"use client"; // Sihirbazın 3 adımlık seçim gövdesi. Hem ana sayfadaki hero modalında hem de // /sonuc'taki sihirbaz modalında kullanılır; seçim state'ini kendi tutar, // tamamlanınca onTamamla(secimler) çağırır. // // Adımlar kademeli süzülür: 2. adımın illeri seçili kategorilere, 3. adımın // üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir. import { useEffect, useMemo, useState } from "react"; import { ArrowLeft, ArrowRight, Search, X } from "lucide-react"; import { useReducedMotion } from "motion/react"; import useMeasure from "react-use-measure"; import { toast } from "@/components/ui/bildirim"; import { cities } from "turkey-map-react/lib/data"; import { TransitionPanel } from "@/components/core/transition-panel"; import { Button } from "@/components/ui/button"; import { IlSecimHaritasi } from "./il-secim-haritasi"; import { normalizeIlAdi } from "@/lib/harita"; import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { olay } from "@/lib/analitik"; import { trBaslikDuzeni } from "@/lib/slug"; import type { SihirbazFacetleri } from "@/types/yokatlas"; import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler"; import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum"; // Seçim çipi tek kaynaktan (DESIGN.md §4.24): seçilince tik büyür, yazı kayar. import { CipGrubu, SecimCipi as Cip } from "@/components/ui/cip-grubu"; const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const; // Haritada karşılığı olan iller (KKTC/yurtdışı kampüsler çip olarak kalır) const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name))); export function SihirbazAdimlar({ sira, tur, facetler, baslangicSecimler, sonButonEtiketi, onTamamla, }: { sira: number; tur: string; facetler: SihirbazFacetleri; baslangicSecimler?: SihirbazSecimleri | null; sonButonEtiketi: string; onTamamla: (secimler: SihirbazSecimleri) => void; }) { const [adim, setAdim] = useState(0); const [yon, setYon] = useState(1); const [gecisBasladi, setGecisBasladi] = useState(false); const [panelRef, panelOlculeri] = useMeasure(); const hareketiAzalt = useReducedMotion(); const [kategoriler, setKategoriler] = useState( baslangicSecimler?.kategoriler ?? [], ); const [iller, setIller] = useState(baslangicSecimler?.iller ?? []); const [universiteTipi, setUniversiteTipi] = useState( baslangicSecimler?.universiteTipi ?? "farketmez", ); const [oncelikler, setOncelikler] = useState( baslangicSecimler?.oncelikler ?? [], ); // Özel kontenjan durumu: isteğe bağlı, havuzu daraltmaz (lib/ozel-durum.ts) const [ozelDurum, setOzelDurum] = useState( baslangicSecimler?.ozelDurum ?? [], ); // Kademeli facet'ler: kategoriler daima ilk yükten gelir; il ve üniversite // tipi sayıları adıma girerken o anki seçimlerle tazelenir. Fetch başarısız // olursa eldeki (daha geniş) liste kalır — sihirbaz asla kilitlenmez. const [canliFacetler, setCanliFacetler] = useState(facetler); // 81 elemanlı listeyi her çip tıklamasında yeniden sıralamamak/filtrelememek // için memo; haritaya giden dizinin referansı da sabit kalır (gereksiz // IlSecimHaritasi render'ı biter). const siraliIller = useMemo( () => [...canliFacetler.iller].sort((a, b) => b.adet - a.adet), [canliFacetler.iller], ); // Türkiye illeri ana çip grubu; KKTC/yurtdışı kampüsler haritada olmadığı // için ayrı alt grupta listelenir. const turkiyeIlleri = useMemo( () => siraliIller.filter((i) => HARITA_ILLERI.has(i.il)), [siraliIller], ); const disIller = useMemo( () => siraliIller.filter((i) => !HARITA_ILLERI.has(i.il)), [siraliIller], ); // İl araması: Türkçe büyük/küçük harf ve şapkalı harf farklarına duyarsız // eşleşme için iki taraf da normalizeIlAdi'dan geçirilir. const [ilArama, setIlArama] = useState(""); const ilAramaNorm = normalizeIlAdi(ilArama.trim()); // 81 çip ilk bakışta boğucu; varsayılan görünüm ilk 10 il + "+N il daha" // rozeti. Arama açıkken kırpma yok (arama zaten görünümü daraltıyor). const [tumIllerAcik, setTumIllerAcik] = useState(false); const gorunenTurkiyeIlleri = useMemo( () => ilAramaNorm ? turkiyeIlleri.filter((i) => normalizeIlAdi(i.il).includes(ilAramaNorm), ) : turkiyeIlleri, [turkiyeIlleri, ilAramaNorm], ); const IL_ON_IZLEME = 10; const ilListesiKirpik = !ilAramaNorm && !tumIllerAcik && gorunenTurkiyeIlleri.length > IL_ON_IZLEME; const listelenenTurkiyeIlleri = ilListesiKirpik ? gorunenTurkiyeIlleri.slice(0, IL_ON_IZLEME) : gorunenTurkiyeIlleri; const gorunenDisIller = useMemo( () => ilAramaNorm ? disIller.filter((i) => normalizeIlAdi(i.il).includes(ilAramaNorm)) : disIller, [disIller, ilAramaNorm], ); const haritaIlleri = useMemo( () => canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il)), [canliFacetler.iller], ); // Huninin paydası: 1. adım hiçbir zaman "geçilmediği" için `adimaGit`ten // atılamıyordu (orası yalnız ileri geçişleri sayar) ve `sihirbaz_adim{1}` // hiç düşmüyordu — payda `sira_girildi`den türetilmek zorunda kalıyordu. // Modal kapanınca bileşen unmount olduğu için mount = "1. adım görüldü"; // 1→2→1→2 gidip gelmek unmount etmediğinden tekrar tetiklenmez. // // Bilerek `olayBirKez` KULLANILMAZ (bkz. lib/analitik.ts:22-24, aynı kural // `liste_uretim_basladi` için yazılı): `:220`'deki ileri geçiş olayı // (`adim:2`, `adim:3`) her geçişte atılıyor. Paydayı oturum başına tekil // yapsaydık sihirbazı ikinci kez açan öğrencide pay 2, payda 1 kalır ve // 1→2 geçiş oranı %100'ü aşardı. Payda ile pay aynı birimde olmalı: // sihirbaz her açıldığında `adim:1` de düşer. // // SERİ KIRILMASI: bu satırın canlıya çıktığı tarihten önce `sihirbaz_adim` // serisinde `adim:1` hiç yok; öncesi/sonrası karşılaştırılamaz. useEffect(() => { olay("sihirbaz_adim", { adim: 1 }); }, []); const [facetYukleniyor, setFacetYukleniyor] = useState(false); useEffect(() => { if (adim === 0) return; const ctrl = new AbortController(); const params = new URLSearchParams({ sira: String(sira), tur }); for (const k of kategoriler) params.append("kategori", k); if (adim === 2) for (const i of iller) params.append("il", i); // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti setFacetYukleniyor(true); // Cache'li: 2↔3 adımları arasında gidip gelmek aynı URL'i yeniden çekmez onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>( `/api/facetler?${params}`, { signal: ctrl.signal }, ) .then((veri) => { setCanliFacetler((mevcut) => ({ ...mevcut, iller: veri.facetler.iller, uniturler: veri.facetler.uniturler, })); // Seçili tip daralan filtrede yoksa (ör. seçilen ilde vakıf kalmadı) // görünmez bir seçim bırakma setUniversiteTipi((tip) => tip !== "farketmez" && !veri.facetler.uniturler.some((u) => u.grup === tip) ? "farketmez" : tip, ); }) .catch(() => {}) .finally(() => setFacetYukleniyor(false)); return () => ctrl.abort(); // Seçimler yalnızca önceki adımlarda değişebildiği için adım geçişinde // okumak yeterli; her çip tıklamasında istek atmayalım. // eslint-disable-next-line react-hooks/exhaustive-deps }, [adim]); function listeDegistir( liste: string[], setListe: (v: string[]) => void, deger: string, maks?: number, ) { if (liste.includes(deger)) { setListe(liste.filter((x) => x !== deger)); } else { if (maks && liste.length >= maks) { toast.info(`En fazla ${maks} seçebilirsin.`); return; } setListe([...liste, deger]); } } const adimGecerli = adim === 0 ? kategoriler.length > 0 : true; function adimaGit(yeniAdim: number) { setYon(yeniAdim > adim ? 1 : -1); setGecisBasladi(true); setAdim(yeniAdim); // Yalnızca ileri geçişler huni adımı sayılır; geri dönüşler tekrar saymasın. // `adim:1` buradan hiç geçmez — onu yukarıdaki mount effect'i atar. if (yeniAdim > adim) olay("sihirbaz_adim", { adim: yeniAdim + 1 }); } const panelYuksekligi = gecisBasladi && panelOlculeri.height > 0 ? panelOlculeri.height : "auto"; return (
{/* Adım göstergesi (DESIGN.md §10): üç parçalı çubuk + adların kendisi; öğrenci nerede olduğunu ve ne kaldığını görür. Değişim ekran okuyucuya duyurulur (WCAG 4.1.3). */}
    {ADIM_ADLARI.map((ad, i) => (
  1. {/* Dolum transform ile (yalnız transform/opaklık canlandırılır); soldan dolar, güçlü ease-out 300 ms. */} {ad}
  2. ))}

Adım {adim + 1} / 3: {ADIM_ADLARI[adim]}

({ x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%", opacity: 0, height: panelYuksekligi, position: "relative", }), center: { zIndex: 1, x: 0, opacity: 1, height: panelYuksekligi, }, exit: (gecisYonu: number) => ({ zIndex: 0, x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%", opacity: 0, position: "absolute", top: 0, left: 0, width: "100%", }), }} transition={ hareketiAzalt ? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x // varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır. { opacity: { duration: 0.12 }, x: { duration: 0 }, height: { duration: 0 }, } : { x: { type: "spring", stiffness: 300, damping: 30 }, height: { type: "spring", stiffness: 300, damping: 30 }, opacity: { duration: 0.2 }, } } >

Hangi alanlara ilgi duyuyorsun?

Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.

{facetler.kategoriler.map((k) => ( listeDegistir(kategoriler, setKategoriler, k.ad)} > {k.ad} {k.adet} ))}

Nerede okumak istersin?

En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.

setIlArama(e.target.value)} placeholder="İl ara..." aria-label="İl ara" autoComplete="off" className="min-w-0 flex-1 bg-transparent text-base text-slate-900 outline-none placeholder:text-slate-400" /> {ilArama ? ( ) : null}
{/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde güvenilir dokunma hedefi değil); iller seçili kategorilere göre süzülmüş, program sayısına göre sıralı listelenir. Varsayılan görünüm ilk 10 il; kalanı "+N il daha" rozetiyle açılır. Arama yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı devre dışı bırakır. */} {listelenenTurkiyeIlleri.map((i) => ( listeDegistir(iller, setIller, i.il, 5)} > {trBaslikDuzeni(i.il)} {i.adet} ))} {ilListesiKirpik ? ( ) : null} {!ilListesiKirpik && tumIllerAcik && !ilAramaNorm && gorunenTurkiyeIlleri.length > IL_ON_IZLEME ? ( ) : null} {ilAramaNorm && gorunenTurkiyeIlleri.length === 0 && gorunenDisIller.length === 0 ? (

“{ilArama.trim()}” ile eşleşen il yok.

) : null} {gorunenDisIller.length > 0 ? (

KKTC & Yurt Dışı

{gorunenDisIller.map((i) => ( listeDegistir(iller, setIller, i.il, 5)} > {trBaslikDuzeni(i.il)} {i.adet} ))}
) : null} {iller.length > 0 ? (
Seçtiklerin: {iller.map((il) => ( ))}
) : null} {/* Harita geniş ekranda görsel destek olarak kalır */}
listeDegistir(iller, setIller, il, 5)} />

Üniversite tipi & öncelikler

Bunlar listenin dengesini belirler.

Üniversite tipi

{canliFacetler.uniturler.map((u) => ( setUniversiteTipi( universiteTipi === u.grup ? "farketmez" : u.grup, ) } > {UNIVERSITE_TIPI_ETIKET[u.grup]} {u.adet} ))} setUniversiteTipi("farketmez")} > Fark etmez

Senin için önemli olan

{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda yedi çip 44 px'le pencereyi 812 px'in dışına itiyordu. */} {ONCELIKLER.map((o) => ( listeDegistir(oncelikler, setOncelikler, o)} > {o} ))}

Listeyi daraltmaz; programların sırasını ve gerekçelerini yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP, kadro YÖK Atlas).

{/* Özel kontenjan durumu (DESIGN.md §4.21): düz ayraç, bilgi tonlu çipler. Cinsiyet ya da sağlık sorulmaz; "34 yaş üstü kadın" ÖSYM kontenjan hakkının beyanıdır. Değer analitiğe gitmez. */}

Özel kontenjan durumun{" "} (isteğe bağlı)

{OZEL_DURUMLAR.map((d) => ( setOzelDurum( ozelDurum.includes(d) ? ozelDurum.filter((x) => x !== d) : OZEL_DURUMLAR.filter((x) => x === d || ozelDurum.includes(x)), ) } > {OZEL_DURUM_ETIKET[d]} ))}

ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o kontenjanı olan programlar listende öne çıkar ve danışman bunu bilir. Cinsiyet ya da sağlık bilgisi sorulmaz.

{adim < 2 ? ( ) : ( )}
); }