All checks were successful
Deploy / deploy (push) Successful in 18m31s
- Updated AGENTS.md with new guidelines for UI consistency, including badge and modal styles. - Refactored layout.tsx to improve the placement of the progressive blur effect. - Enhanced NotFound component text for better user guidance. - Replaced HeroFocusButton with HeroBaslik and KapanisCta in the Home component for improved clarity. - Added SiteFooter to multiple pages for consistent footer display. - Removed ListemSekmeleri component and adjusted ListemPage to streamline functionality. - Updated various components to ensure consistent styling and user experience.
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
"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 "@/components/il-secim-haritasi";
|
||
import { normalizeIlAdi } from "@/lib/harita";
|
||
import { trBaslikDuzeni } from "@/lib/slug";
|
||
import type { SihirbazFacetleri } from "@/lib/db";
|
||
import {
|
||
ONCELIKLER,
|
||
UNIVERSITE_TIPI_ETIKET,
|
||
type SihirbazSecimleri,
|
||
type UniversiteTipi,
|
||
} from "@/lib/sihirbaz";
|
||
|
||
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);
|
||
}
|
||
|
||
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={() =>
|
||
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>
|
||
);
|
||
}
|