Files
kolaytercih/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
bilalgursen 3c995402c6 feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00

599 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string[]>(
baslangicSecimler?.kategoriler ?? [],
);
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
baslangicSecimler?.universiteTipi ?? "farketmez",
);
const [oncelikler, setOncelikler] = useState<string[]>(
baslangicSecimler?.oncelikler ?? [],
);
// Özel kontenjan durumu: isteğe bağlı, havuzu daraltmaz (lib/ozel-durum.ts)
const [ozelDurum, setOzelDurum] = useState<OzelDurum[]>(
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 (
<div className="flex flex-col gap-5">
{/* 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). */}
<div>
<ol className="grid grid-cols-3 gap-2" aria-label="Sihirbaz adımları">
{ADIM_ADLARI.map((ad, i) => (
<li key={ad} className="flex flex-col gap-1.5">
{/* Dolum transform ile (yalnız transform/opaklık canlandırılır);
soldan dolar, güçlü ease-out 300 ms. */}
<span className="h-1.5 overflow-hidden rounded-full bg-slate-200" aria-hidden>
<span
className={`block h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
i <= adim ? "scale-x-100" : "scale-x-0"
}`}
/>
</span>
<span
className={`text-sm ${
i === adim
? "font-semibold text-slate-900"
: i < adim
? "font-medium text-slate-600"
: "text-slate-400"
}`}
aria-current={i === adim ? "step" : undefined}
>
{ad}
</span>
</li>
))}
</ol>
<p role="status" className="sr-only">
Adım {adim + 1} / 3: {ADIM_ADLARI[adim]}
</p>
</div>
<TransitionPanel
activeIndex={adim}
custom={yon}
variants={{
enter: (gecisYonu: number) => ({
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 },
}
}
>
<div ref={panelRef} className="px-px pb-2">
<h3 className="font-heading text-xl font-bold text-slate-900">
Hangi alanlara ilgi duyuyorsun?
</h3>
<p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p>
<CipGrubu etiket="İlgi alanları" className="mt-4">
{facetler.kategoriler.map((k) => (
<Cip
key={k.ad}
secili={kategoriler.includes(k.ad)}
onClick={() => listeDegistir(kategoriler, setKategoriler, k.ad)}
>
{k.ad}
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
</Cip>
))}
</CipGrubu>
</div>
<div ref={panelRef}>
<h3 className="font-heading text-xl font-bold text-slate-900">
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.
</p>
<div className="mt-4 flex h-11 items-center gap-2 rounded-full border border-slate-200 bg-card px-4 transition-colors focus-within:border-slate-300">
<Search className="size-4 shrink-0 text-slate-400" aria-hidden />
<input
type="text"
value={ilArama}
onChange={(e) => 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 ? (
<button
type="button"
onClick={() => setIlArama("")}
aria-label="Aramayı temizle"
className="-mr-2 flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<X className="size-3.5" aria-hidden />
</button>
) : null}
</div>
{/* Ç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. */}
<CipGrubu
etiket="Şehirler"
className={`mt-3 max-h-64 overflow-y-auto sm:max-h-none sm:overflow-visible ${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
{listelenenTurkiyeIlleri.map((i) => (
<Cip
key={i.il}
kucuk
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{trBaslikDuzeni(i.il)}
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
</Cip>
))}
{ilListesiKirpik ? (
<button
type="button"
onClick={() => setTumIllerAcik(true)}
className="inline-flex h-10 cursor-pointer items-center rounded-full border border-dashed border-slate-300 bg-card px-3.5 text-sm font-semibold text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
>
+{gorunenTurkiyeIlleri.length - IL_ON_IZLEME} il daha
</button>
) : null}
{!ilListesiKirpik &&
tumIllerAcik &&
!ilAramaNorm &&
gorunenTurkiyeIlleri.length > IL_ON_IZLEME ? (
<button
type="button"
onClick={() => setTumIllerAcik(false)}
className="inline-flex h-10 cursor-pointer items-center rounded-full border border-dashed border-slate-300 bg-card px-3.5 text-sm font-semibold text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
>
Daha az göster
</button>
) : null}
</CipGrubu>
{ilAramaNorm &&
gorunenTurkiyeIlleri.length === 0 &&
gorunenDisIller.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">
&ldquo;{ilArama.trim()}&rdquo; ile eşleşen il yok.
</p>
) : null}
{gorunenDisIller.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-sm font-semibold text-slate-700">
KKTC & Yurt Dışı
</p>
<CipGrubu
etiket="KKTC ve yurt dışı"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
{gorunenDisIller.map((i) => (
<Cip
key={i.il}
kucuk
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{trBaslikDuzeni(i.il)}
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
</Cip>
))}
</CipGrubu>
</div>
) : null}
{iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-700">
Seçtiklerin:
</span>
{iller.map((il) => (
<button
key={il}
type="button"
onClick={() => listeDegistir(iller, setIller, il)}
className="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-primary px-3.5 text-sm font-semibold text-white transition-[background-color,transform] duration-150 hover:bg-primary/90 active:scale-[0.97]"
>
{trBaslikDuzeni(il)}
<X className="size-4" aria-hidden />
</button>
))}
</div>
) : null}
{/* Harita geniş ekranda görsel destek olarak kalır */}
<div className="mt-4 hidden sm:block">
<IlSecimHaritasi
iller={haritaIlleri}
secili={iller}
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
/>
</div>
</div>
<div ref={panelRef} className="space-y-4">
<div>
<h3 className="font-heading text-xl font-bold text-slate-900">
Üniversite tipi & öncelikler
</h3>
<p className="mt-1 text-sm text-slate-500">
Bunlar listenin dengesini belirler.
</p>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-slate-700">
Üniversite tipi
</p>
<CipGrubu
etiket="Üniversite tipi"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
{canliFacetler.uniturler.map((u) => (
<Cip
key={u.grup}
secili={universiteTipi === u.grup}
onClick={() =>
setUniversiteTipi(
universiteTipi === u.grup ? "farketmez" : u.grup,
)
}
>
{UNIVERSITE_TIPI_ETIKET[u.grup]}
<span className="ml-1.5 text-xs font-medium opacity-70">{u.adet}</span>
</Cip>
))}
<Cip
secili={universiteTipi === "farketmez"}
onClick={() => setUniversiteTipi("farketmez")}
>
Fark etmez
</Cip>
</CipGrubu>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-slate-700">
Senin için önemli olan
</p>
{/* 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. */}
<CipGrubu etiket="Senin için önemli olan" className="grid grid-cols-2 sm:flex sm:flex-wrap">
{ONCELIKLER.map((o) => (
<Cip
key={o}
className="h-auto min-h-11 justify-center px-3 py-1.5 text-center leading-tight sm:h-11 sm:px-4 sm:py-0"
secili={oncelikler.includes(o)}
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
>
{o}
</Cip>
))}
</CipGrubu>
<p className="mt-2 text-sm text-slate-500">
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).
</p>
</div>
{/* Ö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. */}
<div className="border-t border-slate-100 pt-4">
<p className="mb-2 text-sm font-semibold text-slate-700">
Özel kontenjan durumun{" "}
<span className="font-normal text-slate-500">(isteğe bağlı)</span>
</p>
<CipGrubu etiket="Özel kontenjan durumun" className="grid grid-cols-2 sm:flex sm:flex-wrap">
{OZEL_DURUMLAR.map((d) => (
<Cip
key={d}
ton="bilgi"
className="h-auto min-h-11 justify-center px-3 py-1.5 text-center leading-tight sm:h-11 sm:px-4 sm:py-0"
secili={ozelDurum.includes(d)}
onClick={() =>
setOzelDurum(
ozelDurum.includes(d)
? ozelDurum.filter((x) => x !== d)
: OZEL_DURUMLAR.filter((x) => x === d || ozelDurum.includes(x)),
)
}
>
{OZEL_DURUM_ETIKET[d]}
</Cip>
))}
</CipGrubu>
<p className="mt-2 text-sm text-slate-500">
Ö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.
</p>
</div>
</div>
</TransitionPanel>
<div className="flex items-center justify-between gap-2 border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => adimaGit(adim - 1)}
disabled={adim === 0}
className={adim === 0 ? "invisible" : "h-11 cursor-pointer px-3"}
>
<ArrowLeft className="size-4" aria-hidden />
Geri
</Button>
{adim < 2 ? (
<Button
type="button"
onClick={() => adimaGit(adim + 1)}
disabled={!adimGecerli}
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
Devam
<ArrowRight className="size-4" aria-hidden />
</Button>
) : (
<Button
type="button"
onClick={() => {
olay("sihirbaz_tamamlandi", {
kategori_sayisi: kategoriler.length,
il_sayisi: iller.length,
uni_tipi: universiteTipi,
oncelik_sayisi: oncelikler.length,
});
// ozelDurum olaya YAZILMAZ: profil değeri analitiğe gitmez (gizlilik)
onTamamla({ kategoriler, iller, universiteTipi, oncelikler, ozelDurum });
}}
disabled={kategoriler.length === 0}
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
{sonButonEtiketi}
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
</div>
</div>
);
}