Files
kolaytercih/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
bilalgursen 58f4dc221b fix(olcum): huninin paydası ile payı aynı birimde olsun
Denetim Y1: `sihirbaz_adim{1}` `olayBirKez` ile oturum başına tekildi,
`{2}`/`{3}` ise her ileri geçişte atılıyor. Sihirbazı ikinci kez açan
öğrencide (ör. /sonuc'ta "Tercihlerini güncelle") payda 1'de kalıp pay 2
olduğu için 1→2 geçiş oranı %200 görünüyordu. `olayBirKez` → `olay`;
mount zaten "sihirbaz bir kez açıldı"ya eşit, 1→2→1→2 gezinmesi unmount
etmediği için tekrar düşmez.

Kodun kendi yazılı kuralı bunu zaten söylüyordu (lib/analitik.ts:22-24,
`liste_uretim_basladi`): aynı oturumdaki ikinci deneme de sayılmalı,
yoksa payda başarıdan küçük kalır.

KABUL KRİTERİ DÜZELTMESİ: CEO kararı P4/(1) "sihirbaz açılışında tam bir
kez düşer, 1→2→1→2 gidip gelmede tekrar düşmez" diyordu. Denetim bu
kriterin mevcut olay sözleşmesiyle çeliştiğini gösterdi; geçerli kriter
artık "her sihirbaz AÇILIŞINDA bir kez düşer" — oturum içindeki ikinci
açılış da sayılır. 1→2→1→2 gezinmesinde tekrar düşmeme şartı korunuyor.

SERİ KIRILMASI (ikinci, daha önce yazılmamıştı): `paket_goruntulendi`
allowlist'i 3'ten 13 değere çıktığı için tekilleştirme anahtarı çeşitlendi
— olayın oturum başına atılabilme tavanı 4'ten 14'e çıktı ve "diger"
kovası artık yalnız gerçekten bilinmeyen yüzeyleri taşıyor. Bu tarihten
önceki/sonraki toplamlar karşılaştırılamaz. Kod değişmedi, yalnız dosyaya
not düşüldü.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:48:24 +03:00

559 lines
21 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 "sonner";
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";
export function Cip({
secili,
onClick,
children,
kucuk = false,
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
kucuk?: boolean;
}) {
const boyut = kucuk ? "px-2.5 py-1 text-xs" : "px-3.5 py-2 text-sm";
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili
? `cursor-pointer rounded-full border border-primary bg-primary font-medium text-white transition-colors duration-200 ${boyut}`
: `cursor-pointer rounded-full border border-slate-200 bg-white text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 ${boyut}`
}
>
{children}
</button>
);
}
// 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 ?? [],
);
// 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">
<div className="flex items-center gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className={
i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-[width,background-color] duration-200 ease-out"
: i < adim
? "h-1.5 w-3 rounded-full bg-primary/50 transition-[width,background-color] duration-200 ease-out"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-[width,background-color] duration-200 ease-out"
}
/>
))}
<span className="ml-2 text-xs font-medium text-slate-400">
Adım {adim + 1}/3
</span>
</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-lg font-bold">
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>
<div className="mt-4 flex flex-wrap gap-2">
{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 opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</div>
<div ref={panelRef}>
<h3 className="font-heading text-lg font-bold">
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 items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-2 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-sm text-slate-950 outline-none placeholder:text-slate-400"
/>
{ilArama ? (
<button
type="button"
onClick={() => setIlArama("")}
aria-label="Aramayı temizle"
className="flex size-5 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. */}
<div
className={`mt-3 flex max-h-56 flex-wrap gap-1.5 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 text-[10px] opacity-70">{i.adet}</span>
</Cip>
))}
{ilListesiKirpik ? (
<button
type="button"
onClick={() => setTumIllerAcik(true)}
className="cursor-pointer rounded-full border border-dashed border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors duration-200 hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700"
>
+{gorunenTurkiyeIlleri.length - IL_ON_IZLEME} il daha
</button>
) : null}
{!ilListesiKirpik &&
tumIllerAcik &&
!ilAramaNorm &&
gorunenTurkiyeIlleri.length > IL_ON_IZLEME ? (
<button
type="button"
onClick={() => setTumIllerAcik(false)}
className="cursor-pointer rounded-full border border-dashed border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors duration-200 hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700"
>
Daha az göster
</button>
) : null}
</div>
{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-xs font-semibold tracking-wide text-slate-400 uppercase">
KKTC & Yurt Dışı
</p>
<div
className={`flex flex-wrap gap-1.5 ${
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 text-[10px] opacity-70">{i.adet}</span>
</Cip>
))}
</div>
</div>
) : null}
{iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400">
Seçtiklerin:
</span>
{iller.map((il) => (
<button
key={il}
type="button"
onClick={() => listeDegistir(iller, setIller, il)}
className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-orange-600"
>
{trBaslikDuzeni(il)}
<X className="size-3" 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-5">
<div>
<h3 className="font-heading text-lg font-bold">
Ü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-xs font-semibold tracking-wide text-slate-400 uppercase">
Üniversite tipi
</p>
<div
className={`flex flex-wrap gap-2 ${
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 opacity-70">{u.adet}</span>
</Cip>
))}
<Cip
secili={universiteTipi === "farketmez"}
onClick={() => setUniversiteTipi("farketmez")}
>
Fark etmez
</Cip>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Senin için önemli olan
</p>
<div className="flex flex-wrap gap-2">
{ONCELIKLER.map((o) => (
<Cip
key={o}
secili={oncelikler.includes(o)}
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
>
{o}
</Cip>
))}
</div>
<p className="mt-2 text-xs text-slate-500">
Öncelikler yapay zekânın gerekçelerine ve program seçimine yön
verir; listeyi daraltmaz.
</p>
</div>
</div>
</TransitionPanel>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => adimaGit(adim - 1)}
disabled={adim === 0}
className={adim === 0 ? "invisible" : "cursor-pointer"}
>
<ArrowLeft className="size-4" aria-hidden />
Geri
</Button>
{adim < 2 ? (
<Button
type="button"
onClick={() => adimaGit(adim + 1)}
disabled={!adimGecerli}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
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,
});
onTamamla({ kategoriler, iller, universiteTipi, oncelikler });
}}
disabled={kategoriler.length === 0}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{sonButonEtiketi}
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
</div>
</div>
);
}