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

@@ -1,7 +1,7 @@
import Link from "next/link";
import { getAllBolumler, type BolumOzet } from "../katalog-queries";
import { KATEGORILER } from "@/lib/kategoriler";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { PagePixelDivider } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";

View File

@@ -13,7 +13,7 @@ import {
sonucaGitIste,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { olay } from "@/lib/analitik";
import {
ArrowRight,

View File

@@ -10,7 +10,7 @@ import {
} from "../katalog-queries";
import { trBaslikDuzeni } from "@/lib/slug";
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
import { PagePixelDivider } from "@/components/pixel-decor";

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { getAllUniversiteler, type UniOzet } from "../katalog-queries";
import { trBaslikDuzeni } from "@/lib/slug";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { PagePixelDivider } from "@/components/pixel-decor";
import { UniLogo } from "@/components/uni-logo";
import { Skeleton } from "@/components/ui/skeleton";

View File

@@ -4,7 +4,7 @@ import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { sonucHref } from "@/lib/sihirbaz";
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
export function HeroFocusButton() {
function handleClick() {

View File

@@ -17,7 +17,7 @@ import {
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
export type DemoVeri = {

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { getAllRehberler, getAllRehberSlugs, getRehber } from "../rehber-queries";
import { JsonLd, articleJsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { ElCizimiSema } from "./el-cizimi-sema";
import { RehberKapak } from "./rehber-kapak";
import { Skeleton } from "@/components/ui/skeleton";

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

View File

@@ -0,0 +1,49 @@
// Ortak tercih profilinin localStorage katmanı — yalnızca client'ta anlamlı.
// Şema, doğrulama ve sabitler sihirbaz-sabitler.ts'te yaşar.
import {
PROFIL_DEGISTI_EVENT,
SIHIRBAZ_STORAGE_KEY,
tercihProfiliDogrula,
type TercihProfili,
} from "./sihirbaz-sabitler";
function profilDegistiginiYayinla(): void {
window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT));
}
/** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */
export function tercihProfiliOku(): TercihProfili | null {
if (typeof window === "undefined") return null;
try {
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
if (!ham) return null;
return tercihProfiliDogrula(JSON.parse(ham));
} catch {
return null;
}
}
/** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */
export function tercihProfiliYaz(profil: TercihProfili): void {
if (typeof window === "undefined") return;
const dogru = tercihProfiliDogrula(profil);
if (!dogru) return;
try {
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
} catch {}
profilDegistiginiYayinla();
}
/** Profili siler (ör. Yapay Zeka listesi üretilip seçimler tüketildiğinde). */
export function tercihProfiliSil(): void {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
profilDegistiginiYayinla();
}
export function tercihProfiliTamamlandiMi(): boolean {
return tercihProfiliOku() !== null;
}

View File

@@ -0,0 +1,157 @@
// Sihirbazın seçim şeması, doğrulaması ve prompt özeti — izomorfik katman:
// hem client bileşenleri hem server action'ları/AI motoru buradan okur.
// localStorage'a dokunan profil yardımcıları sihirbaz-profil.ts'te yaşar.
// Kategoriler/iller sıralamaya göre dinamik üretilir (bkz. rankWindowFacets);
// öncelikler statik kalır (veriye bağlı değil, tercih niyeti).
import { KATEGORILER } from "@/lib/kategoriler";
import type { UniturGrubu } from "@/types/yokatlas";
export const ONCELIKLER = [
"İş garantisi",
"Yüksek maaş potansiyeli",
"Sevdiğim işi yapmak",
"Akademik kariyer",
"Yurtdışı imkânı",
"Prestijli üniversite",
"Kolay yerleşme",
] as const;
export const UNIVERSITE_TIPI_ETIKET: Record<UniturGrubu | "farketmez", string> = {
devlet: "Devlet",
vakif: "Vakıf (burslu olursa)",
farketmez: "Fark etmez",
};
export type UniversiteTipi = UniturGrubu | "farketmez";
export const PUAN_TURU_ETIKET: Record<string, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT",
};
// Girişsiz kullanıcının modalda yaptığı seçimler, giriş sonrası /sonuc'a
// dönene kadar burada bekler.
export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz";
// storage olayı yalnızca diğer sekmelerde tetiklenir; aynı sekmedeki
// aboneler (ör. navbar sıralama rozeti) bu olayla haberdar edilir.
export const PROFIL_DEGISTI_EVENT = "kolaytercih:profil-degisti";
// Arama modalındaki funnel CTA'sı /sonuc üzerindeyken sihirbaz
// orkestratörünü (SihirbazBolumu) bu olayla açar.
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
export interface SihirbazSecimleri {
kategoriler: string[];
iller: string[];
universiteTipi: UniversiteTipi;
oncelikler: string[];
}
export interface TercihProfili {
sira: number;
tur: string;
secimler: SihirbazSecimleri;
}
const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"];
/**
* Client'tan gelen seçimleri güvene alır. Kategoriler KATEGORILER allowlist'ine
* göre süzülür; iller serbest string (yalnızca sanity — DB IN filtresi zaten
* eşleşmeyeni yok sayar), en fazla 5 il; öncelikler ONCELIKLER'e göre süzülür.
* En az bir kategori zorunlu.
*/
export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null {
if (typeof girdi !== "object" || girdi === null) return null;
const p = girdi as Record<string, unknown>;
const kategoriler = Array.isArray(p.kategoriler)
? [
...new Set(
p.kategoriler.filter(
(x): x is string =>
typeof x === "string" &&
(KATEGORILER as readonly string[]).includes(x),
),
),
]
: [];
if (kategoriler.length === 0) return null;
const iller = Array.isArray(p.iller)
? [
...new Set(
p.iller.filter(
(x): x is string => typeof x === "string" && x.trim().length > 0,
),
),
].slice(0, 5)
: [];
const universiteTipi =
typeof p.universiteTipi === "string" &&
UNI_TIPLERI.includes(p.universiteTipi as UniversiteTipi)
? (p.universiteTipi as UniversiteTipi)
: "farketmez";
const oncelikler = Array.isArray(p.oncelikler)
? [
...new Set(
p.oncelikler.filter(
(x): x is string =>
typeof x === "string" &&
(ONCELIKLER as readonly string[]).includes(x),
),
),
]
: [];
return { kategoriler, iller, universiteTipi, oncelikler };
}
/** localStorage'taki ortak sıra + sihirbaz profilini güvenle doğrular. */
export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null {
if (typeof girdi !== "object" || girdi === null) return null;
const p = girdi as Record<string, unknown>;
const sira = typeof p.sira === "number" ? p.sira : Number(p.sira);
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) return null;
if (typeof p.tur !== "string" || p.tur.trim().length === 0) return null;
const secimler = sihirbazDogrula(p.secimler);
if (!secimler) return null;
return { sira, tur: p.tur, secimler };
}
/** Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu kullanır. */
export function sonucHref(
profil: Pick<TercihProfili, "sira" | "tur">,
ekstra?: Record<string, string>,
): string {
const q = new URLSearchParams({
sira: String(profil.sira),
tur: profil.tur,
...ekstra,
});
return `/sonuc?${q}`;
}
/** Prompt'a enjekte edilecek Türkçe özet. */
export function secimOzeti(secimler: SihirbazSecimleri): string {
const satirlar = [
`- İlgi alanları: ${secimler.kategoriler.join(", ")}`,
];
if (secimler.iller.length > 0) {
satirlar.push(`- Tercih ettiği iller: ${secimler.iller.join(", ")}`);
}
satirlar.push(
`- Üniversite tipi tercihi: ${UNIVERSITE_TIPI_ETIKET[secimler.universiteTipi]}`,
);
if (secimler.oncelikler.length > 0) {
satirlar.push(`- Öncelikleri: ${secimler.oncelikler.join(", ")}`);
}
return satirlar.join("\n");
}