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

@@ -21,7 +21,7 @@ import type {
RaporParams,
RaporSonuc,
} from "@/features/rapor/types/rapor";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { raporMaskele } from "@/lib/rapor-maske";

View File

@@ -4,12 +4,12 @@ import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
import { getSession } from "@/lib/session";
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import { SihirbazBolumu } from "@/features/sihirbaz/components/sihirbaz-cagri-karti";
import { TadimlikSatiri } from "./tadimlik-satiri";
import { ProgramTablosu } from "@/components/program-tablosu";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli";
import { SiraGerekli } from "@/features/sihirbaz/components/sira-gerekli";
export const metadata: Metadata = {
title: "Tercih Listeni Kur",

View File

@@ -1,344 +0,0 @@
"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,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
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

@@ -1,58 +0,0 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { SihirbazAdimlarLazy } from "@/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import type { SihirbazSecimleri } from "@/lib/sihirbaz";
/**
* 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

@@ -1,70 +0,0 @@
"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 "@/lib/sihirbaz";
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

@@ -25,11 +25,8 @@ import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay, siraKovasi } from "@/lib/analitik";
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import {
PROFIL_DEGISTI_EVENT,
sonucHref,
tercihProfiliOku,
} from "@/lib/sihirbaz";
import { PROFIL_DEGISTI_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
const GENEL = "genel";