diff --git a/data/app.db b/data/app.db index 9d0844c..5897576 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx index adfb3b9..38947e3 100644 --- a/src/app/listem/page.tsx +++ b/src/app/listem/page.tsx @@ -2,14 +2,25 @@ import type { Metadata } from "next"; import Link from "next/link"; import { eq } from "drizzle-orm"; import { redirect } from "next/navigation"; -import { FileText, PencilLine, Sparkles } from "lucide-react"; +import { + ArrowRight, + Bot, + Check, + Database, + FileText, + Lock, + PencilLine, + ShieldCheck, + Sparkles, +} from "lucide-react"; import { verifySession, getCurrentUser } from "@/lib/session"; import { appDb, schema } from "@/lib/appdb"; import { PUAN_TURLERI } from "@/lib/db"; +import { URUNLER } from "@/lib/credits"; import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporParams } from "@/lib/rapor-havuzu"; import { Button } from "@/components/ui/button"; -import { Lock } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { RaporListesi } from "@/components/rapor-listesi"; import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske"; import { SohbetClient } from "./sohbet-client"; @@ -18,6 +29,10 @@ export const metadata: Metadata = { title: "Listem — KolayTercih", }; +const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", { + maximumFractionDigits: 0, +}); + export default async function ListemPage() { await verifySession("/listem"); const user = await getCurrentUser(); @@ -36,8 +51,8 @@ export default async function ListemPage() { Henüz bir listen yok

- Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni - kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin. + Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; + sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.

- - - } - /> +
+
+ +
+ + + +
+

+ Ham veri burada bitmiyor — aşağıdaki tüm satırlar açık +

+

+ Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk, + gerekçe ve AI yorumunu açar. +

+
+
+ +
+ } + /> +
- + {user.hasPaket ? ( + + ) : ( +
+
+
+ + + Tercih Dönemi Paketi + +
+

+ Tablo ücretsiz. +
+ Karar desteği pakette. +

+

+ Sadece veriyi göstermez; hangi tercihin neden mantıklı + olduğunu sıralamana göre açıklar. +

+
+ +
+
    + {[ + "24 tercihin tamamında kişisel risk analizi", + "Her program için kısa, gerekçeli AI yorumu", + "2 liste revizyonu ve paylaşılabilir PDF", + `${URUNLER.paket.credits} AI danışman sorusu`, + ].map((ozellik) => ( +
  • + + {ozellik} +
  • + ))} +
+ +
+
+

Tek seferlik

+

+ {PAKET_FIYATI} TL +

+
+ Abonelik yok +
+ + + +
+ + + iyzico güvenli ödeme + + + + Gerçek YÖK verisi + +
+
+
+ )}
diff --git a/src/app/sonuc/funnel-banner.tsx b/src/app/sonuc/funnel-banner.tsx index 1d994e3..08ac17f 100644 --- a/src/app/sonuc/funnel-banner.tsx +++ b/src/app/sonuc/funnel-banner.tsx @@ -1,68 +1,140 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; -import { ArrowRight, Sparkles } from "lucide-react"; +import { + ArrowRight, + ChartNoAxesCombined, + Database, + ListChecks, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { TypingAnimation } from "@/components/ui/typing-animation"; /** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */ export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac"; -const CUMLELER = [ - "Seçmekte zorlanıyor musunuz?", - "Hangi bölüm size uygun, emin değil misiniz?", - "Yapay zekâ 24 tercihlik listenizi kursun.", - "Her satırda gerekçe ve yerleşme riski analizi.", +const FUNNEL_METINLERI = [ + "Sıralamana en uygun listeyi birlikte kuralım.", + "Geleceğin 24 satırlık bir plana sığar.", + "Gerçek YÖK Atlas verisiyle güvenle seç.", + "Her tercihinin bir nedeni olsun.", + "Riskleri biz hesaplayalım, kararı sen ver.", + "Sana uygun bölümler bir adım uzağında.", + "Dengeli bir liste, rahat bir tercih dönemi.", + "Tercihlerini şansa bırakma.", + "Doğru strateji, doğru yerleşme.", + "Hayalindeki bölüm için doğru plan.", + "Verilerle konuşan bir tercih listesi.", + "Üç adımda sana özel 24 tercih.", + "Tercih dönemini stressiz geçir.", + "Geçen yılın verisi, senin avantajın.", + "Güvenli ve iddialı tercihleri dengele.", + "Sıralaman ne söylüyor? Birlikte bakalım.", + "Her satırı senin için hesaplıyoruz.", + "Akıllı tercih, içi rahat bir bekleyiş.", + "Yerleşme ihtimalini görerek seç.", + "Sana özel liste birkaç dakikada hazır.", + "İlgi alanların ve şehrin, tek listede.", + "Boş kalan tercih, kaçan fırsattır.", + "Uzman işi bir liste, senin onayınla.", + "Kararını veriye dayandır.", + "Doğru tercih, doğru bilgiyle başlar.", + "Listeni bugün kur, için rahat olsun.", + "Yerleşme riskin renklerle önünde.", + "En önemli 24 kararını birlikte verelim.", + "Senin sıralaman, sana özel plan.", + "İyi bir liste, iyi bir başlangıçtır.", +]; + +/** Fisher-Yates ile kopya üzerinde karıştırır; her ziyarette farklı sıra. */ +function karistir(dizi: readonly T[]): T[] { + const kopya = [...dizi]; + for (let i = kopya.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [kopya[i], kopya[j]] = [kopya[j], kopya[i]]; + } + return kopya; +} + +const KANITLAR = [ + { ikon: ListChecks, etiket: "24 tercih" }, + { ikon: ChartNoAxesCombined, etiket: "Risk analizi" }, + { ikon: Database, etiket: "YÖK Atlas verisi" }, ]; /** * Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı. - * Sürekli değişen (yazılıp silinen) cümlelerle adayı sihirbaza; rapor zaten - * varsa kilitli listenin durduğu /listem paywall'ına iter. - * Tasarım: flat indigo — gradient/gölge yok, CTA funnel turuncusu. + * Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel + * metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin + * durduğu /listem paywall'ına iter. + * Tasarım: güven veren lacivert yüzey, veri kanıtları ve turuncu CTA. */ export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) { + // İlk render'da bir kez karıştırılır; SSR boş metinle başladığı için + // hydration uyuşmazlığı yaratmaz. + const [metinler] = useState(() => karistir(FUNNEL_METINLERI)); + const ctaSinif = - "h-10 shrink-0 cursor-pointer bg-orange-500 px-5 font-semibold text-white transition-colors duration-200 hover:bg-orange-600 focus-visible:ring-white/70"; + "h-12 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"; return (
-
-
- +
+
+ +
+
+ + +
+ {KANITLAR.map(({ ikon: Ikon, etiket }) => ( + + + {etiket} + + ))} +
- -
- {hedefListem ? ( - + ) : ( + - ) : ( - - )} + + )} +
); } diff --git a/src/app/sonuc/liste-paneli.tsx b/src/app/sonuc/liste-paneli.tsx index 239a894..6881216 100644 --- a/src/app/sonuc/liste-paneli.tsx +++ b/src/app/sonuc/liste-paneli.tsx @@ -47,8 +47,7 @@ function pinleriTuret( rapor.tercihler.forEach((t, i) => { const p = rapor.programlar[t.programId]; if (!p) return; - const risk = - riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim); + const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim); const kilitli = i >= kilitliBaslangic; if (kilitli) { @@ -192,7 +191,7 @@ export function ListePaneli({

{sira.toLocaleString("tr-TR")}. sıra ·{" "} {kilitli - ? `ilk ${ACIK_SATIR} tercih açık` + ? `tüm taban verileri açık · ${ACIK_SATIR} AI analiz örneği` : `${kredi} kredin kaldı`}

@@ -236,29 +235,35 @@ export function ListePaneli({ }} kilitliBaslangic={kilitli ? ACIK_SATIR : undefined} kilitOverlay={ - <> - -

- 24 tercihin {24 - ACIK_SATIR}'i kilidin ardında -

-

- Her satır için gerekçe, risk notu ve 4 yıllık trend + PDF çıktı - + 2 revizyon hakkı + 60 kredi seni bekliyor. -

+
+
+ +
+

+ Tüm program ve taban verileri ücretsiz +

+

+ Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk, + gerekçe, PDF, 2 revizyon ve 60 AI danışman sorusunu açar. +

+ {kredi > 0 ? ( +

+ Kalan {kredi} deneme kredin hesabında duruyor. +

+ ) : null} +
+
- {kredi > 0 ? ( -

- Kalan {kredi} kredin AI danışmana soru sormak için cebinde - duruyor. -

- ) : null} - +
} /> @@ -280,6 +285,7 @@ export function ListePaneli({ function UretimGovdesi() { const [mesajIdx, setMesajIdx] = useState(0); + const govdeRef = useRef(null); useEffect(() => { const interval = setInterval(() => { @@ -288,8 +294,19 @@ function UretimGovdesi() { return () => clearInterval(interval); }, []); + // Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri + // veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz. + useEffect(() => { + govdeRef.current?.focus({ preventScroll: true }); + }, []); + return ( -
+

Listen hazırlanıyor

diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx index 39871c1..efe3aed 100644 --- a/src/app/sonuc/page.tsx +++ b/src/app/sonuc/page.tsx @@ -124,31 +124,14 @@ export default async function SonucPage({ kırmızı riskli.

- {/* Puan türü seçimi */} - - {/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor varsa /listem paywall'ına) iten banner — paketli kullanıcıya satacak bir şey kalmadığından gösterilmez */} {!hasPaket ? : null} {/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana - gövdesi; + ile manuel 24'lük liste kurulur */} - + gövdesi; puan türü seçici de listenin başında yaşar */} + {/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli sayfanın en altında yaşar */} diff --git a/src/app/sonuc/sihirbaz-cagri-karti.tsx b/src/app/sonuc/sihirbaz-cagri-karti.tsx index 98a8069..e5321a8 100644 --- a/src/app/sonuc/sihirbaz-cagri-karti.tsx +++ b/src/app/sonuc/sihirbaz-cagri-karti.tsx @@ -1,14 +1,11 @@ "use client"; -import { - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Sparkles, X } from "lucide-react"; +import { animate } from "motion"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import type { SihirbazFacetleri } from "@/lib/db"; @@ -19,11 +16,7 @@ import { } from "@/lib/sihirbaz"; import { SihirbazModal } from "./sihirbaz-modal"; import { SIHIRBAZ_AC_EVENT } from "./funnel-banner"; -import { - ListePaneli, - type MevcutRapor, - type PanelDurum, -} from "./liste-paneli"; +import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli"; import { listeOlustur, listeRevize } from "./actions"; const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali"; @@ -75,6 +68,7 @@ export function SihirbazBolumu({ }) { const router = useRouter(); const monteEdildi = useMonteEdildi(); + const hareketiAzalt = useReducedMotion(); const [elleAcik, setElleAcik] = useState(false); const [elleKapandi, setElleKapandi] = useState(false); @@ -94,6 +88,7 @@ export function SihirbazBolumu({ // Aynı üretim için sabit requestId (retry çifte harcama yapmaz) const requestIdRef = useRef(null); const otomatikBasladi = useRef(false); + const bolumRef = useRef(null); // Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku useEffect(() => { @@ -131,8 +126,61 @@ export function SihirbazBolumu({ return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac); }, []); + /** + * Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da + * sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız; + * böylece Motion ile giren hedef panel görünür alanda kalır. + */ + function bolumuGorunureAl() { + const el = bolumRef.current; + if (!el) return; + const kutu = el.getBoundingClientRect(); + if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return; + el.scrollIntoView({ block: "center", behavior: "instant" }); + } + + async function modalCikisiniOynat() { + if (hareketiAzalt) return; + + const modal = document.querySelector("[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( + '[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; + } + async function uret(secimler: SihirbazSecimleri) { if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); + bolumuGorunureAl(); + await modalCikisiniOynat(); setElleAcik(false); setDurum("uretiliyor"); @@ -167,16 +215,20 @@ export function SihirbazBolumu({ secimleriKaydetVeGirise(secimler); } else { toast.error(sonuc.error); - setDurum("bos"); - setElleAcik(true); + modalaDon(); } } catch { toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?"); - setDurum("bos"); - setElleAcik(true); + modalaDon(); } } + /** Hata sonrası panelden modala dönüş. */ + function modalaDon() { + setDurum("bos"); + setElleAcik(true); + } + function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) { try { localStorage.setItem( @@ -188,13 +240,14 @@ export function SihirbazBolumu({ router.push(`/giris?callback=${encodeURIComponent(geri)}`); } - function ilerle(secimler: SihirbazSecimleri) { + async function ilerle(secimler: SihirbazSecimleri) { setSonSecimler(secimler); if (!girisliMi) { + await modalCikisiniOynat(); secimleriKaydetVeGirise(secimler); return; } - void uret(secimler); + await uret(secimler); } // Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla @@ -227,76 +280,119 @@ export function SihirbazBolumu({ } const panelde = Boolean(rapor) || durum !== "bos"; + const panelAnahtari = + durum !== "bos" ? durum : rapor ? "rapor-hazir" : "sihirbaz-cta"; return ( <> {/* AI çıktısı (rapor listesi/haritası) bu sayfada render edilmez; panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel durumlarını gösterir, listenin kendisi /listem'de yaşar. */} - {durum !== "bos" ? ( - { - const sonuc = await listeRevize({ - feedback, - requestId: crypto.randomUUID(), - }); - if (sonuc.ok) { - setRapor({ - rapor: sonuc.rapor, - params: sonuc.params, - revisionCount: sonuc.revisionCount, - }); - setKredi(sonuc.kredi); - toast.success("Listen yeniden kuruldu."); - } else { - toast.error(sonuc.error); +
+ + - ) : rapor ? ( -
-

- 24 tercihlik listen hazır -

-

- Yapay zekânın kurduğu risk analizli listen Listem sayfasında seni - bekliyor. -

- -
- ) : ( -
-

- Bu listeyi 24 tercihlik plana çevirelim mi? -

-

- Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli, - dengeli bir tercih listesi kuralım — her satır için yerleşme riski - analiziyle. -

- -
- )} + {durum !== "bos" ? ( + { + const sonuc = await listeRevize({ + feedback, + requestId: crypto.randomUUID(), + }); + if (sonuc.ok) { + setRapor({ + rapor: sonuc.rapor, + params: sonuc.params, + revisionCount: sonuc.revisionCount, + }); + setKredi(sonuc.kredi); + toast.success("Listen yeniden kuruldu."); + } else { + toast.error(sonuc.error); + } + return sonuc.ok; + }} + /> + ) : rapor ? ( +
+

+ 24 tercihlik listen hazır +

+

+ Yapay zekânın kurduğu risk analizli listen Listem sayfasında + seni bekliyor. +

+ +
+ ) : ( +
+

+ Bu listeyi 24 tercihlik plana çevirelim mi? +

+

+ Birkaç adımda ilgi alanların ve şehir tercihine göre + gerekçeli, dengeli bir tercih listesi kuralım — her satır için + yerleşme riski analiziyle. +

+ +
+ )} +
+
+
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */} {monteEdildi && !panelde && !kartGizle && !acik ? ( @@ -334,7 +430,9 @@ export function SihirbazBolumu({ ) : null} - {monteEdildi ? ( + {/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion + üstlenir ve odağı panel içeriğine taşır. */} + {monteEdildi && durum === "bos" ? ( - + Tercih listesi sihirbazı Sıralamana uygun 24 tercihlik listeni oluştur. diff --git a/src/components/il-secim-haritasi.tsx b/src/components/il-secim-haritasi.tsx index 3c11f88..c5ad774 100644 --- a/src/components/il-secim-haritasi.tsx +++ b/src/components/il-secim-haritasi.tsx @@ -5,7 +5,12 @@ // kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir. import { cities } from "turkey-map-react/lib/data"; -import { normalizeIlAdi } from "@/lib/harita"; +import { + HARITA_GENISLIK, + HARITA_UST_KIRPMA, + HARITA_YUKSEKLIK, + normalizeIlAdi, +} from "@/lib/harita"; export function IlSecimHaritasi({ iller, @@ -24,7 +29,7 @@ export function IlSecimHaritasi({ return (
= { }; export function ListeCekmecesi() { - const liste = useManuelListe(); const acik = useCekmeceAcik(); + + return {acik ? : null}; +} + +function CekmeceSheet() { + const liste = useManuelListe(); const azMotion = useReducedMotion(); const sheetKontrol = useDragControls(); + // Giriş spring'i bitmeden satırların layout projeksiyonu kapalı kalır — + // aksi halde transform'lu sheet içinde alınan ölçüm satırları kaydırıyor + const [oturdu, setOturdu] = useState(false); // Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar useEffect(() => { - if (!acik) return; const dinle = (e: KeyboardEvent) => { if (e.key === "Escape") cekmeceKapat(); }; window.addEventListener("keydown", dinle); return () => window.removeEventListener("keydown", dinle); - }, [acik]); + }, []); const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100); @@ -77,151 +84,159 @@ export function ListeCekmecesi() { }; return ( - <> - - - - {acik ? ( - -
- {/* Tutamak: buradan aşağı çekilince sheet kapanır */} -
{ - if (!azMotion) sheetKontrol.start(e); - }} - className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing" + } + transition={{ type: "spring", duration: 0.4, bounce: 0.15 }} + onAnimationComplete={() => setOturdu(true)} + drag={azMotion ? false : "y"} + dragListener={false} + dragControls={sheetKontrol} + dragConstraints={{ top: 0, bottom: 0 }} + dragElastic={{ top: 0, bottom: 0.6 }} + onDragEnd={surukleyinceKapat} + aria-label="Manuel tercih listem" + className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3" + > + {/* layout: satır silinince/eklenince kart yüksekliği spring ile oturur. + Doğrudan çocuklar layout="position" taşır ki ölçekleme sırasında + içerik yamulmasın (scale-correction zinciri). */} + + {/* Tutamak: buradan aşağı çekilince sheet kapanır */} + { + if (!azMotion) sheetKontrol.start(e); + }} + className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing" + > +
+ + + {/* Başlık + doluluk */} + +
+

+ + Listem + + {liste.length}/{MANUEL_LISTE_MAX} + +

+
+ {liste.length > 0 ? ( + + ) : null} + - ) : null} - -
-
-
- -
-

- Sıralamanla ulaşabildiğin programlardan kendi listen — - tutamaktan sürükleyip sırala, sağa kaydırıp sil. -

-
- - {/* Gövde: sürüklenebilir satırlar */} - {liste.length === 0 ? ( -
- -

- Listen henüz boş -

-

- Sonuç sayfasındaki tablodan ile - program ekle; 24 tercihlik listen burada birikir. -

-
- ) : ( - - - {liste.map((t, i) => ( - - ))} - - - )} - - {/* Alt bant: AI danışman köprüsü */} -
- - - AI listeni ve danışmanı aç - -
+ + Kapat +
- - ) : null} - - +
+
+ +
+

+ Sıralamanla ulaşabildiğin programlardan kendi listen — tutamaktan + sürükleyip sırala, sağa kaydırıp sil. +

+ + + {/* Gövde: sürüklenebilir satırlar */} + {liste.length === 0 ? ( + + +

+ Listen henüz boş +

+

+ Sonuç sayfasındaki tablodan ile + program ekle; 24 tercihlik listen burada birikir. +

+
+ ) : ( + + + {liste.map((t, i) => ( + + ))} + + + )} + + {/* Alt bant: AI danışman köprüsü */} + + + + AI listeni ve danışmanı aç + + + + ); } @@ -231,9 +246,11 @@ const SIL_ESIK = 90; function CekmeceSatiri({ tercih, sira, + projeksiyonAcik, }: { tercih: ManuelTercih; sira: number; + projeksiyonAcik: boolean; }) { const stil = tercih.risk ? RISK_STIL[tercih.risk] : null; const siraKontrol = useDragControls(); @@ -255,8 +272,8 @@ function CekmeceSatiri({ ); } - -// --------------------------------------------------------------------------- -// Uçuş katmanı: "+" butonundan Listem hedefine süzülen pill -// --------------------------------------------------------------------------- - -type Ucus = UcusIstegi & { id: number; hedef: { x: number; y: number } }; - -let ucusSayac = 0; - -/** Hedef nokta: çekmece açıksa içindeki sayaç, değilse navbar'daki buton. */ -function hedefBul(): { x: number; y: number } | null { - const el = - document.querySelector("[data-cekmece-hedef]") ?? - document.querySelector("[data-listem-hedef]"); - if (!el) return null; - const r = el.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; -} - -function UcusKatmani() { - const [ucuslar, setUcuslar] = useState([]); - const azMotion = useReducedMotion(); - - useEffect(() => { - if (azMotion) return; // reduced motion: rozet pulse yeter - return ucusAbone((istek) => { - const hedef = hedefBul(); - if (!hedef) return; - setUcuslar((u) => [...u, { ...istek, id: ucusSayac++, hedef }]); - }); - }, [azMotion]); - - if (ucuslar.length === 0) return null; - - return ( -
- {ucuslar.map((u) => ( - - setUcuslar((mevcut) => mevcut.filter((x) => x.id !== u.id)) - } - className="absolute left-0 top-0" - > - {/* Merkezleme iç sarmalayıcıda: motion'ın x/y transformu ile çakışmasın */} -
- {u.tercih.risk ? ( - - ) : null} - - {u.tercih.isim} - -
-
- ))} -
- ); -} diff --git a/src/components/manuel-liste/listem-butonu.tsx b/src/components/manuel-liste/listem-butonu.tsx index adfec0b..93efd93 100644 --- a/src/components/manuel-liste/listem-butonu.tsx +++ b/src/components/manuel-liste/listem-butonu.tsx @@ -1,8 +1,7 @@ "use client"; // Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca -// çekmeceyi açar/kapatır. Uçuş animasyonunun kapalı-çekmece hedefi de budur -// (data-listem-hedef). Sayaç değişince rozet spring ile "pulse" yapar. +// çekmeceyi açar/kapatır. Sayaç değişince rozet spring ile "pulse" yapar. import { ListChecks } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; @@ -21,7 +20,6 @@ export function ListemButonu() { return (