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:
@@ -10,7 +10,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/lib/sihirbaz";
|
||||
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
profilToplamaAc,
|
||||
useTercihProfili,
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChartNoAxesCombined,
|
||||
Database,
|
||||
ListChecks,
|
||||
Trophy,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import {
|
||||
SihirbazAdimlarLazy,
|
||||
preloadSihirbazAdimlar,
|
||||
} from "@/components/sihirbaz-adimlar-lazy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import {
|
||||
sonucHref,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
|
||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||
const PUAN_TURLERI_SECENEK = [
|
||||
{ deger: "say", etiket: "Sayısal" },
|
||||
{ deger: "ea", etiket: "Eşit Ağırlık" },
|
||||
{ deger: "soz", etiket: "Sözel" },
|
||||
{ deger: "dil", etiket: "Dil" },
|
||||
{ deger: "tyt", etiket: "TYT" },
|
||||
] as const;
|
||||
|
||||
// Kanıt üçlüsü arama funnel kartıyla aynı dili konuşur (bkz. arama-funnel-karti)
|
||||
const KANITLAR = [
|
||||
{ ikon: ListChecks, etiket: "24 tercihlik plan" },
|
||||
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
|
||||
{ ikon: Database, etiket: "YÖK Atlas verisi" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Programatik SEO sayfalarındaki funnel girişi. Statik/SSR çıktı her zaman
|
||||
* profilsiz hâldir (sayfalar prerender edilmeye devam eder); hydration
|
||||
* sonrası kayıtlı tercih profili bulunursa "sıralamanı gir" yerine doğrudan
|
||||
* sonuç sayfasına götüren CTA gösterilir — sıralamasını girmiş adaya bir
|
||||
* daha girmesini söylemeyiz.
|
||||
*
|
||||
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
|
||||
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
|
||||
* Tasarım gerekçesi için bkz. docs/tasarim/cta-cekmece-tasarimi.md.
|
||||
*/
|
||||
export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
const profil = useTercihProfili();
|
||||
const router = useRouter();
|
||||
|
||||
const [acik, setAcik] = useState(false);
|
||||
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
|
||||
const [siralama, setSiralama] = useState("");
|
||||
const [tur, setTur] = useState<string>("say");
|
||||
const [sira, setSira] = useState<number | null>(null);
|
||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [hata, setHata] = useState<string | null>(null);
|
||||
const [girisli, setGirisli] = useState(false);
|
||||
|
||||
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
|
||||
function siralamaDegisti(ham: string) {
|
||||
setHata(null);
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
}
|
||||
|
||||
async function siraGonder(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = Number(siralama.replace(/\./g, ""));
|
||||
if (!value || value < 1 || value > 4_000_000) {
|
||||
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||||
return;
|
||||
}
|
||||
setYukleniyor(true);
|
||||
try {
|
||||
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
|
||||
// (hero formundaki desenle aynı — bkz. hero-form.tsx).
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${value}&tur=${tur}`),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(value);
|
||||
setFacetler(veri.facetler);
|
||||
setAdim("sihirbaz");
|
||||
olay("sira_girildi", {
|
||||
kaynak: "cta",
|
||||
tur,
|
||||
sira_kovasi: siraKovasi(value),
|
||||
});
|
||||
} catch {
|
||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||
} finally {
|
||||
setYukleniyor(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Seçimler tamam: sakla, çekmeceyi kapat ve /sonuc'a git. Girişli
|
||||
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
|
||||
// ücretsiz tabloyu görür (değer önce — giriş duvarı sihirbazın hemen
|
||||
// arkasında değil).
|
||||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
||||
if (sira == null) return;
|
||||
const yeniProfil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(yeniProfil);
|
||||
setAcik(false);
|
||||
router.push(
|
||||
sonucHref(yeniProfil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
|
||||
);
|
||||
}
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
setAcik(v);
|
||||
if (!v) {
|
||||
// Yeniden açılışta sıfırdan başla; yazılan sıralama kaybolmasın.
|
||||
setAdim("sira");
|
||||
setFacetler(null);
|
||||
setHata(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (profil) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
{baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra) —
|
||||
gerçek YÖK Atlas verisiyle hayal, dengeli ve garanti dilimlerindeki
|
||||
programların hazır.
|
||||
</p>
|
||||
<Link
|
||||
href={sonucHref(profil)}
|
||||
className="mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-orange-500 px-7 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Sıralamana uygun programları gör
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
{baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
YKS başarı sıralamanı gir; gerçek YÖK Atlas verisiyle sıralamana uygun
|
||||
hayal, dengeli ve garanti dilimlerindeki programları anında listele.
|
||||
Ücretsiz, üyelik gerekmez.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setAcik(true)}
|
||||
onPointerEnter={preloadSihirbazAdimlar}
|
||||
onFocus={preloadSihirbazAdimlar}
|
||||
className="mt-4 h-11 cursor-pointer bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Sıralamanı gir, programları gör
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
|
||||
<DrawerContent
|
||||
data-lenis-prevent
|
||||
className="max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||||
>
|
||||
{/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */}
|
||||
<DrawerTitle className="sr-only">
|
||||
Sıralamanla nereye yerleşirsin?
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan
|
||||
ayrılmadan gör.
|
||||
</DrawerDescription>
|
||||
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</DrawerClose>
|
||||
|
||||
<div
|
||||
data-lenis-prevent
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
|
||||
>
|
||||
{adim === "sira" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<SectionEyebrow>Saniyede gör</SectionEyebrow>
|
||||
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900">
|
||||
Sıralamanla nereye yerleşirsin?
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
|
||||
Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve
|
||||
garanti dilimlerindeki programları bu sayfadan ayrılmadan
|
||||
gör. Ücretsiz, üyelik gerekmez.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={siraGonder} className="flex flex-col gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{PUAN_TURLERI_SECENEK.map((t) => (
|
||||
<button
|
||||
key={t.deger}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tur === t.deger}
|
||||
onClick={() => setTur(t.deger)}
|
||||
className={
|
||||
tur === t.deger
|
||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
{t.etiket}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label htmlFor="cta-siralama" className="sr-only">
|
||||
YKS başarı sıralaması
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="relative sm:flex-1">
|
||||
<Trophy
|
||||
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
id="cta-siralama"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="YKS başarı sıralaman (ör. 85.000)"
|
||||
value={siralama}
|
||||
onChange={(e) => siralamaDegisti(e.target.value)}
|
||||
onFocus={preloadSihirbazAdimlar}
|
||||
aria-invalid={hata ? true : undefined}
|
||||
aria-describedby={
|
||||
hata ? "cta-siralama-hata" : undefined
|
||||
}
|
||||
className="h-12 bg-background pl-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={yukleniyor}
|
||||
className="h-12 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Programları gör"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
{hata ? (
|
||||
<p
|
||||
id="cta-siralama-hata"
|
||||
role="alert"
|
||||
className="text-sm font-medium text-red-600"
|
||||
>
|
||||
{hata}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<ul
|
||||
aria-label="Neler alacaksın"
|
||||
className="flex flex-wrap gap-x-4 gap-y-2 border-t border-slate-100 pt-4"
|
||||
>
|
||||
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
|
||||
<li
|
||||
key={etiket}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600"
|
||||
>
|
||||
<Ikon className="size-3.5 text-primary" aria-hidden />
|
||||
{etiket}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : facetler && sira != null ? (
|
||||
<SihirbazAdimlarLazy
|
||||
key={`${sira}-${tur}`}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
sonButonEtiketi={
|
||||
girisli ? "Yapay Zeka listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
|
||||
}
|
||||
onTamamla={secimleriTamamla}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Wrench, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { tercihProfiliSil } from "@/lib/sihirbaz";
|
||||
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
devAnindaGiris,
|
||||
devAyarla,
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
SihirbazAdimlarLazy,
|
||||
preloadSihirbazAdimlar,
|
||||
} from "@/components/sihirbaz-adimlar-lazy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import {
|
||||
PUAN_TURU_ETIKET,
|
||||
sonucHref,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
|
||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||
const PUAN_TURLERI_SECENEK = [
|
||||
{ deger: "say", etiket: "Sayısal" },
|
||||
{ deger: "ea", etiket: "Eşit Ağırlık" },
|
||||
{ deger: "soz", etiket: "Sözel" },
|
||||
{ deger: "dil", etiket: "Dil" },
|
||||
{ deger: "tyt", etiket: "TYT" },
|
||||
] as const;
|
||||
|
||||
export function HeroForm() {
|
||||
const [siralama, setSiralama] = useState("");
|
||||
const [tur, setTur] = useState<string>("say");
|
||||
const [sira, setSira] = useState<number | null>(null);
|
||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [modalAcik, setModalAcik] = useState(false);
|
||||
const [hata, setHata] = useState<string | null>(null);
|
||||
const [girisli, setGirisli] = useState(false);
|
||||
// Daha önce sıralama girmiş ziyaretçi formu yeniden doldurmasın:
|
||||
// kayıtlı profil varsa doğrudan /sonuc CTA'sı gösterilir.
|
||||
const kayitliProfil = useTercihProfili();
|
||||
const router = useRouter();
|
||||
|
||||
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
|
||||
function siralamaDegisti(ham: string) {
|
||||
setHata(null);
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = Number(siralama.replace(/\./g, ""));
|
||||
if (!value || value < 1 || value > 4_000_000) {
|
||||
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||||
return;
|
||||
}
|
||||
setYukleniyor(true);
|
||||
try {
|
||||
// Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür:
|
||||
// landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır).
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${value}&tur=${tur}`),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(value);
|
||||
setFacetler(veri.facetler);
|
||||
setModalAcik(true);
|
||||
olay("sira_girildi", {
|
||||
kaynak: "hero",
|
||||
tur,
|
||||
sira_kovasi: siraKovasi(value),
|
||||
});
|
||||
} catch {
|
||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||
} finally {
|
||||
setYukleniyor(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Seçimler tamam: sakla ve herkes doğrudan /sonuc'a gitsin. Girişli
|
||||
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
|
||||
// ücretsiz tabloyu görür, giriş CTA'sı ödülün kaydedilmesi olarak
|
||||
// oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil).
|
||||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
||||
if (!sira) return;
|
||||
const profil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(profil);
|
||||
setModalAcik(false);
|
||||
router.push(
|
||||
sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
|
||||
);
|
||||
}
|
||||
|
||||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||||
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
|
||||
// (TercihProfiliKapisi, layout'ta monte) açar.
|
||||
if (kayitliProfil) {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href={sonucHref(kayitliProfil)}>
|
||||
Üniversitelere göz at
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-slate-600">
|
||||
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
|
||||
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
|
||||
devam edersin.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
|
||||
>
|
||||
Farklı sıralama gir
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
|
||||
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
|
||||
<div className="flex w-full max-w-xl flex-col items-center gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
className="flex flex-wrap justify-center gap-1.5"
|
||||
>
|
||||
{PUAN_TURLERI_SECENEK.map((t) => (
|
||||
<button
|
||||
key={t.deger}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tur === t.deger}
|
||||
onClick={() => setTur(t.deger)}
|
||||
className={
|
||||
tur === t.deger
|
||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
{t.etiket}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full flex-col gap-3 sm:flex-row"
|
||||
>
|
||||
<label htmlFor="siralama" className="sr-only">
|
||||
YKS başarı sıralaması
|
||||
</label>
|
||||
<Input
|
||||
id="siralama"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="YKS başarı sıralaman (ör. 85.000)"
|
||||
value={siralama}
|
||||
onChange={(e) => siralamaDegisti(e.target.value)}
|
||||
onFocus={preloadSihirbazAdimlar}
|
||||
aria-invalid={hata ? true : undefined}
|
||||
aria-describedby={hata ? "siralama-hata" : undefined}
|
||||
// flex-1 yalnızca yatay dizilimde (sm+): mobilde form flex-col olduğu
|
||||
// için flex-1 inputun yüksekliğini eziyordu. Mobilde geniş padding
|
||||
// + büyük yazı; sm ve üzeri eski görünüm.
|
||||
className="h-14 bg-background px-6 py-3 text-lg sm:h-12 sm:flex-1 sm:px-2.5 sm:py-1 sm:text-base"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={yukleniyor}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{hata ? (
|
||||
<p
|
||||
id="siralama-hata"
|
||||
role="alert"
|
||||
className="self-start text-sm font-medium text-red-600"
|
||||
>
|
||||
{hata}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog open={modalAcik} onOpenChange={setModalAcik}>
|
||||
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 text-left sm:max-w-3xl">
|
||||
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Sıralamana uygun seçimlerini yap, tercih planına geç.
|
||||
</DialogDescription>
|
||||
{facetler && sira ? (
|
||||
<SihirbazAdimlarLazy
|
||||
key={`${sira}-${tur}`}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
sonButonEtiketi={
|
||||
girisli ? "Yapay Zeka listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
|
||||
}
|
||||
onTamamla={secimleriTamamla}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Sihirbazın "Nerede okumak istersin?" adımı: çip listesi yerine dokunarak
|
||||
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu
|
||||
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
|
||||
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import {
|
||||
HARITA_GENISLIK,
|
||||
HARITA_UST_KIRPMA,
|
||||
HARITA_YUKSEKLIK,
|
||||
normalizeIlAdi,
|
||||
} from "@/lib/harita";
|
||||
|
||||
export function IlSecimHaritasi({
|
||||
iller,
|
||||
secili,
|
||||
onToggle,
|
||||
}: {
|
||||
/** Bu sıralama penceresinde programı olan iller (DB biçimi ad + adet) */
|
||||
iller: { il: string; adet: number }[];
|
||||
secili: string[];
|
||||
onToggle: (il: string) => void;
|
||||
}) {
|
||||
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
|
||||
const maxAdet = Math.max(1, ...iller.map((i) => i.adet));
|
||||
const seciliSet = new Set(secili);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
|
||||
className="h-auto w-full"
|
||||
role="group"
|
||||
aria-label="Okumak istediğin illeri haritadan seç"
|
||||
>
|
||||
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
|
||||
{[...cities]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(seciliSet.has(normalizeIlAdi(a.name))) -
|
||||
Number(seciliSet.has(normalizeIlAdi(b.name))),
|
||||
)
|
||||
.map((city) => {
|
||||
const key = normalizeIlAdi(city.name);
|
||||
const adet = adetler.get(key);
|
||||
const seciliMi = seciliSet.has(key);
|
||||
const yogunluk = adet ? adet / maxAdet : 0;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
d={city.path}
|
||||
className={
|
||||
adet
|
||||
? "cursor-pointer transition-opacity duration-200 hover:opacity-75"
|
||||
: undefined
|
||||
}
|
||||
fill={
|
||||
seciliMi
|
||||
? "oklch(0.705 0.213 47.6)" // orange-500
|
||||
: adet
|
||||
? `oklch(0.623 0.188 259.8 / ${0.25 + 0.65 * yogunluk})`
|
||||
: "oklch(0.929 0.013 255.5)" // slate-200
|
||||
}
|
||||
stroke={seciliMi ? "oklch(0.553 0.195 38.4)" : "white"}
|
||||
strokeWidth={seciliMi ? 2 : 1}
|
||||
onClick={adet ? () => onToggle(key) : undefined}
|
||||
role={adet ? "button" : undefined}
|
||||
aria-pressed={adet ? seciliMi : undefined}
|
||||
aria-label={
|
||||
adet ? `${city.name} — ${adet} program` : undefined
|
||||
}
|
||||
>
|
||||
<title>
|
||||
{adet
|
||||
? `${city.name}: ${adet} program — ${seciliMi ? "seçimi kaldır" : "seçmek için tıkla"}`
|
||||
: `${city.name}: bu sıralamada program yok`}
|
||||
</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||
<span>az</span>
|
||||
<span aria-hidden className="flex items-center gap-1">
|
||||
<span className="size-2.5 rounded-full bg-primary/25" />
|
||||
<span className="size-2.5 rounded-full bg-primary/55" />
|
||||
<span className="size-2.5 rounded-full bg-primary/90" />
|
||||
</span>
|
||||
<span>çok program</span>
|
||||
<span className="ml-3 inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2.5 rounded-full bg-orange-500"
|
||||
/>
|
||||
seçili
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { tercihProfiliDogrula, type TercihProfili } from "@/lib/sihirbaz";
|
||||
import { tercihProfiliDogrula, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
|
||||
@@ -11,11 +11,8 @@
|
||||
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
tercihProfiliOku,
|
||||
tercihProfiliYaz,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { PROFIL_DEGISTI_EVENT } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku, tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
|
||||
import { oturumDurumuBildir } from "./oturum-sinyali";
|
||||
import { listeSenkronla, listeyiKaydet } from "./kayitli-liste-actions";
|
||||
|
||||
@@ -5,13 +5,8 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
sonucHref,
|
||||
tercihProfiliOku,
|
||||
tercihProfiliTamamlandiMi,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { PROFIL_DEGISTI_EVENT, sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku, tercihProfiliTamamlandiMi } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
ekle,
|
||||
listedeMi,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
tercihDegerlendir,
|
||||
tercihListesiOzeti,
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { riskHesapla } from "@/lib/risk";
|
||||
import {
|
||||
tercihProfiliOku,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
|
||||
@@ -25,14 +25,11 @@ import {
|
||||
import {
|
||||
SihirbazAdimlarLazy,
|
||||
preloadSihirbazAdimlar,
|
||||
} from "@/components/sihirbaz-adimlar-lazy";
|
||||
} from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import {
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
profilKapisiniKapat,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// SihirbazAdimlar, turkey-map-react'in ~235 KB'lık il geometrisini taşır.
|
||||
// Modal kapalıyken bu yükün ilk sayfa bundle'ına girmemesi için tek noktadan
|
||||
// dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır.
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function SihirbazAdimlarSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="flex min-h-64 flex-col gap-5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-1.5 w-6 rounded-full" />
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
<Skeleton className="ml-2 h-3 w-14" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-6 w-64 max-w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{[88, 112, 96, 128, 72, 104, 120, 84, 100].map((w, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-9 rounded-full"
|
||||
style={{ width: w }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
|
||||
<Skeleton className="h-9 w-20 rounded-lg" />
|
||||
<Skeleton className="h-9 w-24 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SihirbazAdimlarLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/sihirbaz-adimlar").then((m) => m.SihirbazAdimlar),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <SihirbazAdimlarSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
/** Kullanıcı forma dokunduğu anda chunk'ı arka planda indir. */
|
||||
export function preloadSihirbazAdimlar() {
|
||||
void import("@/components/sihirbaz-adimlar");
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
"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 { 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 "@/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);
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user