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,319 @@
"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 "./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, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
// 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>
);
}

View File

@@ -0,0 +1,230 @@
"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 "./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, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
// 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>
);
}

View File

@@ -0,0 +1,101 @@
"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>
);
}

View File

@@ -0,0 +1,58 @@
"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("./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("./sihirbaz-adimlar");
}

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>
);
}

View File

@@ -0,0 +1,339 @@
"use client";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { animate } from "motion";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { PixelField } from "@/components/pixel-decor";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay } from "@/lib/analitik";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { SIHIRBAZ_AC_EVENT, SIHIRBAZ_STORAGE_KEY, sihirbazDogrula, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
import { SihirbazModal } from "./sihirbaz-modal";
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
const LISTEM_URET_HREF = "/listem?uret=1";
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
const bosAbone = () => () => {};
const useMonteEdildi = () =>
useSyncExternalStore(
bosAbone,
() => true,
() => false,
);
type BekleyenSecim = {
secimler: SihirbazSecimleri;
eslesir: boolean; // storage'daki sıra/tur bu sayfayla aynı mı
};
/**
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
* - Sayfa ortasındaki büyük CTA (rapor yokken)
* - Adımları toplayan modal
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
*/
export function SihirbazBolumu({
sira,
tur,
facetler,
girisliMi,
raporVar,
otomatikAc,
secimlerHazir,
}: {
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
raporVar: boolean;
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean;
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
render edilsin. */
secimlerHazir?: boolean;
}) {
const router = useRouter();
const monteEdildi = useMonteEdildi();
const hareketiAzalt = useReducedMotion();
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
const [depoOkundu, setDepoOkundu] = useState(false);
const otomatikBasladi = useRef(false);
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
const bekleyenRef = useRef<BekleyenSecim | null>(null);
// Mount sonrası storage'dan bekleyen seçimleri oku
useEffect(() => {
try {
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
let yeniBekleyen: BekleyenSecim | null = null;
if (ham) {
const veri = JSON.parse(ham) as {
sira?: number;
tur?: string;
secimler?: unknown;
};
const secimler = sihirbazDogrula(veri.secimler);
if (secimler) {
yeniBekleyen = {
secimler,
eslesir: veri.sira === sira && veri.tur === tur,
};
}
}
if (yeniBekleyen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setBekleyen(yeniBekleyen);
bekleyenRef.current = yeniBekleyen;
}
} catch {}
setDepoOkundu(true);
}, [sira, tur]);
// Arama modalındaki funnel kartının CTA'sı sihirbazı bu olayla başlatır
useEffect(() => {
const ac = () => sihirbaziBaslat();
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function modalCikisiniOynat() {
if (hareketiAzalt) return;
const modal = document.querySelector<HTMLElement>("[data-sihirbaz-modal]");
if (!modal) return;
const mevcutTransform = getComputedStyle(modal).transform;
const hedefTransform =
mevcutTransform === "none"
? "translate3d(0, 48px, 0)"
: new DOMMatrixReadOnly(mevcutTransform).translate(0, 48).toString();
const perde = document.querySelector<HTMLElement>(
'[data-slot="dialog-overlay"]',
);
const modalAnimasyonu = animate(
modal,
{
opacity: 0,
transform: hedefTransform,
},
{
duration: 0.24,
ease: [0.4, 0, 0.7, 0.2],
},
);
if (perde) {
await Promise.all([
modalAnimasyonu,
animate(perde, { opacity: 0 }, { duration: 0.2, ease: "easeOut" }),
]);
return;
}
await modalAnimasyonu;
}
/**
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
* /listem'e geç. Girişsiz kullanıcı önce girişe uğrar; callback doğrudan
* /listem'e düştüğünden üretim dönüşte orada otomatik başlar.
*/
async function ilerle(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
await modalCikisiniOynat();
setElleAcik(false);
if (!girisliMi) {
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
return;
}
router.push(LISTEM_URET_HREF);
}
/**
* Funnel CTA'larının (banner, büyük kart) ortak girişi:
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
* kullanıcı doğrudan /listem'e gider, girişsiz kullanıcı girişe gider.
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
*/
function sihirbaziBaslat() {
const hazir = bekleyenRef.current;
if (hazir?.eslesir) {
void ilerle(hazir.secimler);
return;
}
setElleAcik(true);
setElleKapandi(false);
}
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
// açılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
const otomatikGonder = Boolean(
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
);
useEffect(() => {
if (otomatikGonder && !otomatikBasladi.current) {
otomatikBasladi.current = true;
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
router.replace(LISTEM_URET_HREF);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikGonder]);
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
// hiç gösterilmez, /listem'e geçilir.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
function acikDegisti(v: boolean) {
if (v) {
setElleAcik(true);
setElleKapandi(false);
} else {
setElleKapandi(true);
setElleAcik(false);
}
}
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
return (
<>
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
listenin kendisi /listem'de yaşar. */}
{raporVar ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section
ref={hazirPanelRef}
className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12"
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır — Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
}
>
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi — her satırı
yerleşme riski ve gerekçesiyle — senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
{monteEdildi ? (
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler}
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
sonButonEtiketi={
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
}
onTamamla={ilerle}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { SihirbazAdimlarLazy } from "./sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import type { SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
/**
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
* /listem'de yaşar (bkz. listem/liste-uretici.tsx). Akış yönetimi
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
*/
export function SihirbazModal({
acik,
onAcikDegisti,
sira,
tur,
facetler,
baslangicSecimler,
sonButonEtiketi,
onTamamla,
}: {
acik: boolean;
onAcikDegisti: (acik: boolean) => void;
sira: number;
tur: string;
facetler: SihirbazFacetleri;
baslangicSecimler?: SihirbazSecimleri | null;
sonButonEtiketi: string;
onTamamla: (secimler: SihirbazSecimleri) => void;
}) {
return (
<Dialog open={acik} onOpenChange={onAcikDegisti}>
<DialogContent
data-sihirbaz-modal
className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"
>
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
<DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur.
</DialogDescription>
<SihirbazAdimlarLazy
sira={sira}
tur={tur}
facetler={facetler}
baslangicSecimler={baslangicSecimler}
sonButonEtiketi={sonButonEtiketi}
onTamamla={onTamamla}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Trophy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import {
sonucaGitIste,
useProfilKapisi,
} from "@/components/manuel-liste/profil-kapisi-store";
export function SiraGerekli() {
const router = useRouter();
const { profil, profilYuklendi } = useProfilKapisi();
const [yonleniyor, setYonleniyor] = useState(false);
useEffect(() => {
if (!profilYuklendi) return;
if (profil) {
setYonleniyor(true);
router.replace(sonucHref(profil));
}
}, [profil, profilYuklendi, router]);
if (!profilYuklendi || yonleniyor || profil) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor…</p>
</main>
);
}
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<h1 className="font-heading text-2xl font-bold">
Geçerli bir sıralama gerekli
</h1>
<p className="mt-3 text-slate-600">
Listeni kurabilmek için YKS başarı sıralamanı gir. Kaydettiğin sıra tüm
uygulamada kullanılır.
</p>
<PagePixelDivider seed={59} className="mx-auto mt-6" />
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<Button
type="button"
onClick={() => {
const { href } = sonucaGitIste();
if (href) router.push(href);
}}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Trophy className="size-4" aria-hidden />
Sıralamamı gir
</Button>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/">
<ArrowLeft className="size-4" aria-hidden />
Ana sayfaya dön
</Link>
</Button>
</div>
</main>
);
}