refactor: sihirbaz feature'ı — lib/sihirbaz bölündü, bileşenler taşındı (Faz 6)

- lib/sihirbaz.ts ikiye ayrıldı: sihirbaz-sabitler.ts (şema + doğrulayıcılar +
  event/storage sabitleri + sonucHref/secimOzeti, izomorfik) ve
  sihirbaz-profil.ts (localStorage oku/yaz/sil); 25 tüketicinin importu
  sembol bazında bölünerek güncellendi, localStorage anahtar/event değerleri
  birebir aynı
- sihirbaz-adimlar(+lazy), hero-form, cta-sira-form, il-secim-haritasi ve
  /sonuc'un sihirbaz-modal/sihirbaz-cagri-karti/sira-gerekli bileşenleri
  features/sihirbaz/components/ altına taşındı
- sihirbaz-queries.ts ve async SihirbazBolumu sarmalayıcısı /sonuc sayfa
  dönüşümüyle birlikte Faz 9'da gelecek (o zamana dek ölü kod olurlardı)
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 01:31:32 +03:00
parent baebc1fc80
commit 792e8c7346
36 changed files with 102 additions and 128 deletions

View File

@@ -0,0 +1,397 @@
"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, useState } from "react";
import { ArrowLeft, ArrowRight, 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 { 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,
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{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);
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);
fetch(`/api/facetler?${params}`, { signal: ctrl.signal })
.then((res) => (res.ok ? res.json() : null))
.then((veri: { facetler: SihirbazFacetleri } | null) => {
if (!veri) return;
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
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>
{/* Ç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. */}
<div
className={`mt-4 flex flex-wrap gap-2 ${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
{[...canliFacetler.iller]
.sort((a, b) => b.adet - a.adet)
.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{trBaslikDuzeni(i.il)}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
</div>
{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={canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
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>
</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>
);
}