# Booking pill → şekil değiştiren hap (SekilHapi) — kaldırılan uygulama, saklanan mantık 7 Eki 2026. Bilal Arc UI "Booking pill" (https://uiarc.dev/components/booking-pill, **Pro**) davranışını üst çubuk hapına istedi; uygulandı, tarayıcıda doğrulandı, sonra Bilal'in kararıyla koddan geri alındı. Bu belge mantığı ve kodu saklar; yeniden açmak için aşağıdaki iki dosya `src/` altına koyulur ve en sondaki yama uygulanır (`git apply`). ## 1. Demodan ölçülen davranış (Chrome, DOM + computedStyle + rAF örnekleme) Kaynak kodu Pro'ya kilitli, alınmadı; canlı önizleme adım adım tıklanıp ölçüldü. | Adım | Çerçeve (px) | İçerik | |---|---|---| | kapalı | 164×52 | takvim ikonu + "Book a table" | | kişi | 300×60 | geri (44) · −/sayı rulosu/+ (36) · ileri (44), padding 8, gap 6 | | tarih | 420×124 | bar (geri · başlık · ileri) + sürüklenen gün şeridi (slider, pan-y) | | saat | 360×262 | başlık + alt satır · 4 sütun 81×40 radyo (kayan vurgu) · tam genişlik CTA 44 | | bilet | 340×215 | mekân adı 22/500, adres 14, dl Date/Time/Guests, kesik koparma hattı, koçanda geri + CTA | | rezerve | 244×56 | yeşil onay diski 32 + iki satır metin + yenile butonu 36 | - Yüzey: `rgba koyu /0.8` + `backdrop blur(24px) saturate(1.6)`, köşe sabit 30 px, iç 1 px halka, dış gölge `0 22px 55px rgba(0,0,0,.34)`. Yalnız o anki yüz monte; her yüz ResizeObserver'lı. - Geçiş (örnekleme, ms): çıkan yüz ~100 ms'de opaklık 1→0, ölçek 1→0,97, x 0→−10..−16 (gidiş yönünün tersi), blur 0→4 px. Giren yüz ~90 ms gecikmeyle opaklık 0→1, ölçek 0,95→1, x +16→0, blur 4→0 (~250 ms). Çerçeve genişlik/yükseklik sekmesiz yayla ~400 ms'de yeni ölçüye oturur (340×215 → 240×50; eşzamanlı, içerik bir vuruş geriden). - Klavye: ok tuşları şeritte gün / ızgarada saat, PgUp/PgDn hafta, Home/End, Enter onay, Escape bir adım geri. Erişilebilirlik: şerit `role=slider` + `aria-valuetext`, saatler `radiogroup`, adım değişimi `aria-live=polite`, hata `role=alert`. ## 2. Bizdeki tasarım kararları - Genel hap mekaniği (`SekilHapi`) ile akış (`SiralamaHapi`) ayrıldı. - Yüzey DESIGN.md dili: `rounded-3xl border-slate-200 bg-card` (kapalı 48 px hap tam yuvarlak), gölge yalnız açıkken `shadow-[0_24px_48px_-20px_rgba(15,23,42,0.3)]`, camsı blur yok. Yaylar `src/lib/hareket.ts`: çerçeve `YAY.donusum`, çıkış `SURE.anlik`, giriş `SURE.standart` + `EGRI.giris`, vuruş 90 ms, kayma 16 px. Hareketi azalt: ölçü zıplar, yalnız opaklık. - SSR tuzağı: çerçeve ilk boyamada `auto` (doğal akış), yoksa hidrasyon öncesi nav görünmez kalırdı. İlk adım değişiminde çerçeve o anki `offsetWidth/Height`'a kilitlenir (`jump`), yüzler mutlak konuma geçer; kapalıya dönüp yay bitince tekrar `auto` (duyarlı yerleşim). Ölçü `offsetWidth` ile (getBoundingClientRect dönüşümü içerir, giren yüz %95 ölçekte). - Akış eşlemesi: kişi → **puan türü** (SecimHaplari ayrık), tarih → **sıralama** (hapın içinde kenarlıksız ham input, §4.6 istisnası), saat → yok, bilet → **bilet** (iri sayı + puan türü / tercihler + koparma hattı + turuncu CTA), rezerve → **kaydedildi** (2 s sonra kendiliğinden kapanır). - Profil yoksa bilet "Tercihlere geç": hap kapanır, kapı modalı `onSira` ile açılır, facetleri yükler ve doğrudan alan/il adımına geçer (sıra iki kez sorulmaz). Profil varsa "Kaydet": sıra+tür yerinde yazılır, alan/il korunur, `profilTamamlandi` riskleri yeniler, `router.refresh()`. - lg altı satır modal yolunda kaldı. Sihirbaz adımları değişmediği için meraklısına şeması güncellenmedi. - Doğrulama (localhost:3000, 1280 px, açık+koyu): 466→519→351→(hata 345×80)→322×191 geçişleri, modalın Adım 1/3'te açılması, Kaydet → kaydedildi → 2 s kapanış çalıştı. tsc/lint/denetim temiz. - Tarayıcı notu: `javascript_tool` koşarken pane arka planda sayılır, rAF durur; motion animasyonları JS içindeki bekleyişle ilerlemez. Zamanlama için `computer.wait` kullan. ## 3. Kod — `src/components/ui/sekil-hapi.tsx` ```tsx "use client"; // Şekil değiştiren hap: tek bir hap yüzeyi, içindeki adıma göre yayla // yeniden biçimlenir; içerik bir vuruş sonra gidiş yönünde kayarak gelir. // // Kaynak: Arc UI "Booking pill" (uiarc.dev, Pro — kodu alınmadı; 7 Eki 2026'da // canlı önizlemeden ölçüldü). Oradaki ölçüm: çerçeve genişlik/yükseklik // ~0,4 s'lik sekmesiz yayla yeni yüzün ölçüsüne gider; çıkan yüz ~0,1 s'de // söner (opaklık 0, %97 ölçek, 16 px geriye, 4 px blur), giren yüz ~90 ms // gecikmeyle karşı taraftan aynı yoldan gelir; yalnız o anki yüz monte, çıkan // yüz çıkış süresince üstte kalır. Bizdeki farklar: yay `YAY.donusum`, süreler // `SURE`, eğri `EGRI` (src/lib/hareket.ts); koyu camsı yüzey yerine // `border-slate-200 bg-card` hapı; 30 px köşe yerine `rounded-3xl` (kapalı // 48 px hap tam yuvarlak kalır); gölge yalnız açıkken. Hareketi azalt'ta ölçü // zıplar, yüzler yalnız opaklıkla değişir. // // Çalışma biçimi: ilk boyamada (SSR dâhil) hap doğal akışta ölçülür, çerçeve // `auto`'dur — hidrasyon öncesi görünmez kalmaz. İlk adım değişiminde çerçeve // o anki ölçüsüne kilitlenir, yüzler mutlak konuma geçer ve çerçeve yeni // yüzün ölçüsüne yaylanır. Açık yüz büyüyüp küçülürse (hata satırı) çerçeve // onu da izler (ResizeObserver). İlk adıma dönülüp hareket bitince doğal // akışa geri döner ki üst çubuk duyarlı yerleşimini korusun. import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type Variants, } from "motion/react"; import { cn } from "@/lib/utils"; import { EGRI, SURE, YAY } from "@/lib/hareket"; /** Yüzün gidiş yönünde kaydığı mesafe (px). */ const KAYMA = 16; /** Giren yüzün çerçeveden sonra başladığı vuruş (s). */ const VURUS = 0.09; export interface SekilHapiProps { /** Görünen adımın anahtarı; değişince yüz değişir. */ adim: string; /** Kapalı adım: burada çerçeve doğal akışa döner, dış tıklama dinlenmez. */ ilkAdim: string; /** +1 ileri, -1 geri — içerik bu yönde kayar. */ yon: 1 | -1; /** Ekran okuyucuya adım değişimini söyleyen cümle. */ duyuru?: string; /** Escape: bir adım geri. */ onGeri?: () => void; /** Açıkken hap dışına basılınca. */ onDisariTiklama?: () => void; /** Hapın ekran okuyucu adı. */ etiket: string; className?: string; /** O anki adımın yüzü; `data-odak` taşıyan öğe yüz gelince odaklanır. */ children: ReactNode; } export function SekilHapi({ adim, ilkAdim, yon, duyuru, onGeri, onDisariTiklama, etiket, className, children, }: SekilHapiProps) { const azalt = useReducedMotion(); const cerceveRef = useRef(null); // Doğal akış (false) ↔ ölçülü çerçeve (true). Ref, efektlerin bayat // kapanış okumaması için state'in aynası. const [olculu, setOlculu] = useState(false); const olculuRef = useRef(false); const adimRef = useRef(adim); useLayoutEffect(() => { adimRef.current = adim; }, [adim]); const genislik = useMotionValue("auto"); const yukseklik = useMotionValue("auto"); const acik = adim !== ilkAdim; /** Çerçeveyi (kenarlık dâhil) yüz ölçüsüne götürür. */ const hedefeGit = useCallback( (w: number, h: number) => { const cerceve = cerceveRef.current; if (!cerceve || !olculuRef.current) return; const ekW = cerceve.offsetWidth - cerceve.clientWidth; const ekH = cerceve.offsetHeight - cerceve.clientHeight; const gecis = azalt ? { duration: 0 } : YAY.donusum; const hareket = Promise.all([ animate(genislik, w + ekW, gecis), animate(yukseklik, h + ekH, gecis), ]); void hareket.then(() => { // Kapalı adıma dönüş bitti: doğal akışa geri (duyarlı yerleşim). if (adimRef.current !== ilkAdim || !olculuRef.current) return; olculuRef.current = false; genislik.set("auto"); yukseklik.set("auto"); setOlculu(false); }); }, [azalt, genislik, yukseklik, ilkAdim], ); /** Yeni yüz monte oldu: çerçeveyi olduğu ölçüye kilitle, sonra hedefe yaylan. */ const gecisBaslat = useCallback( (w: number, h: number) => { const cerceve = cerceveRef.current; if (!cerceve) return; if (!olculuRef.current) { olculuRef.current = true; genislik.jump(cerceve.offsetWidth); yukseklik.jump(cerceve.offsetHeight); setOlculu(true); } hedefeGit(w, h); }, [genislik, yukseklik, hedefeGit], ); useEffect(() => { if (!acik || !onDisariTiklama) return; function dinle(olay: PointerEvent) { const cerceve = cerceveRef.current; if (cerceve && !cerceve.contains(olay.target as Node)) onDisariTiklama?.(); } document.addEventListener("pointerdown", dinle); return () => document.removeEventListener("pointerdown", dinle); }, [acik, onDisariTiklama]); function tusBasildi(olay: KeyboardEvent) { if (olay.key !== "Escape" || !onGeri || !acik) return; olay.preventDefault(); olay.stopPropagation(); onGeri(); } return (
{children}

{duyuru}

); } const YUZ_VARYANT: Variants = { giris: (yon: 1 | -1) => ({ opacity: 0, x: KAYMA * yon, scale: 0.95, filter: "blur(4px)", }), orta: { opacity: 1, x: 0, scale: 1, filter: "blur(0px)", transition: { delay: VURUS, duration: SURE.standart, ease: EGRI.giris }, }, cikis: (yon: 1 | -1) => ({ opacity: 0, x: -KAYMA * yon, scale: 0.97, filter: "blur(4px)", transition: { duration: SURE.anlik, ease: EGRI.standart }, }), }; const YUZ_VARYANT_AZALT: Variants = { giris: { opacity: 0 }, orta: { opacity: 1, transition: { duration: SURE.hizli } }, cikis: { opacity: 0, transition: { duration: SURE.hizli } }, }; function Yuz({ dogal, azalt, onGecis, onOlcu, children, }: { /** Doğal akıştaki ilk yüz: çerçeveyi kilitlemez, ölçü bildirmez. */ dogal: boolean; azalt: boolean; onGecis: (w: number, h: number) => void; onOlcu: (w: number, h: number) => void; children: ReactNode; }) { const ref = useRef(null); // Monte: ölç (dönüşümsüz offset ölçüsü), çerçeveyi başlat, odağı ver; // sonra yüzün kendi boyut değişimlerini izle (hata satırı açılır vb.). useLayoutEffect(() => { const el = ref.current; if (!el) return; if (!dogal) onGecis(el.offsetWidth, el.offsetHeight); el.querySelector("[data-odak]")?.focus({ preventScroll: true }); const gozlemci = new ResizeObserver(() => { onOlcu(el.offsetWidth, el.offsetHeight); }); gozlemci.observe(el); return () => gozlemci.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps -- yalnız mount }, []); return ( {children} ); } ``` ## 4. Kod — `src/features/sihirbaz/components/siralama-hapi.tsx` ```tsx "use client"; // Üst çubuğun ortasındaki gezinme hapı (lg+): kapalıyken "Sıralamanı gir" // (ya da kayıtlı sıra özeti) + "Nasıl çalışır?" + Ara; dokununca AYNI hap // adım adım yeniden biçimlenir — puan türü → başarı sıralaması → bilet // (özet) → kaydedildi. Arc UI "Booking pill" akışının (kişi → tarih → saat → // bilet → rezerve) bizdeki karşılığı; hap mekaniği SekilHapi'de (DESIGN.md // §4.33), bu dosya yalnız yüzleri ve akışı bilir. // // İki yol: // - Profil yok: bilet "Tercihlere geç" der → hap kapanır, tercih profili // kapısı (modal) sıra+türü hazır alır, facetleri yükler ve doğrudan alan/il // adımıyla açılır (`tercihlereGec`). Sıra adımı modalda tekrar sorulmaz. // - Profil var: bilet "Kaydet" der → profil yerinde yazılır (alan/il // seçimleri korunur), riskler yenilenir, "kaydedildi" yüzü bir vuruş durup // kapanır. Alan/il de değişecekse bilet üstündeki bağlantı modalı açar. // lg altındaki ikinci satır bu hapı kullanmaz (modal yolu, katalog-arama.tsx). import { useEffect, useEffectEvent, useState, type ReactNode } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowLeft, ArrowRight, Check, SquarePen, Trophy, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SecimHaplari } from "@/components/ui/secim-haplari"; import { SekilHapi } from "@/components/ui/sekil-hapi"; import { preloadTercihProfiliKapisi } from "@/features/liste/components/tercih-profili-kapisi-lazy"; import { profilDuzenlemeyiAc, profilTamamlandi, tercihlereGec, useTercihProfili, } from "@/features/liste/hooks/use-tercih-profili"; import { PUAN_TURU_ETIKET, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil"; import { olay, siraKovasi } from "@/lib/analitik"; import { cn } from "@/lib/utils"; 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; type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"]; function puanTuruMu(deger: string): deger is PuanTuru { return PUAN_TURLERI_SECENEK.some((s) => s.deger === deger); } type Adim = "kapali" | "tur" | "sira" | "bilet" | "kaydedildi"; const SIRA: Record = { kapali: 0, tur: 1, sira: 2, bilet: 3, kaydedildi: 4, }; /** Kaydedildi yüzünün ekranda kaldığı süre (ms); sonra hap kendiliğinden kapanır. */ const KAYDEDILDI_BEKLEME = 2000; const YUVARLAK = "flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full transition-[background-color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none active:scale-[0.96]"; const GERI = cn(YUVARLAK, "bg-slate-100 text-slate-700 hover:bg-slate-200"); const ILERI = cn(YUVARLAK, "bg-slate-900 text-slate-50 hover:bg-slate-700"); export function SiralamaHapi({ araButonu }: { araButonu: ReactNode }) { const router = useRouter(); const profil = useTercihProfili(); const [adim, setAdim] = useState("kapali"); const [yon, setYon] = useState<1 | -1>(1); const [tur, setTur] = useState("say"); const [siralama, setSiralama] = useState(""); const [hata, setHata] = useState(null); const siraDeger = Number(siralama.replace(/\./g, "")); const turEtiketi = PUAN_TURU_ETIKET[tur] ?? tur; function adimaGec(yeni: Adim) { setYon(SIRA[yeni] > SIRA[adim] ? 1 : -1); setAdim(yeni); } function ac() { preloadTercihProfiliKapisi(); setHata(null); setTur(profil && puanTuruMu(profil.tur) ? profil.tur : "say"); setSiralama(profil ? profil.sira.toLocaleString("tr-TR") : ""); adimaGec("tur"); } function kapat() { setHata(null); adimaGec("kapali"); } function geri() { if (adim === "sira") adimaGec("tur"); else if (adim === "bilet") adimaGec("sira"); else kapat(); } function siralamaDegisti(ham: string) { setHata(null); const rakamlar = ham.replace(/\D/g, "").slice(0, 7); setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : ""); } function siraGonder(e: React.FormEvent) { e.preventDefault(); if (!siraDeger || siraDeger < 1 || siraDeger > 4_000_000) { setHata("Geçerli bir başarı sıralaması gir (ör. 85.000)."); return; } setHata(null); adimaGec("bilet"); } /** Profil yok: sıra+tür kapıya verilir, kapı facetleri yükleyip alan/il adımıyla açılır. */ function tercihlereDevam() { kapat(); tercihlereGec({ sira: siraDeger, tur }); } /** Profil var: sıra+tür yerinde yazılır, alan/il seçimleri korunur. */ function kaydet() { if (!profil) return; const yeni: TercihProfili = { sira: siraDeger, tur, secimler: profil.secimler }; tercihProfiliYaz(yeni); // Depodaki profili tazeler, listedeki riskleri yeni sıraya göre yeniler. profilTamamlandi(yeni); olay("sira_girildi", { kaynak: "duzenle", tur, sira_kovasi: siraKovasi(siraDeger), }); // /sonuc tablosu profil çerezinden SSR'lanır; URL değişmediği için tazele. router.refresh(); adimaGec("kaydedildi"); } function tercihleriDeDegistir() { kapat(); profilDuzenlemeyiAc(); } const kendiligindenKapat = useEffectEvent(kapat); useEffect(() => { if (adim !== "kaydedildi") return; const zamanlayici = window.setTimeout(kendiligindenKapat, KAYDEDILDI_BEKLEME); return () => window.clearTimeout(zamanlayici); }, [adim]); const duyuru: Record = { kapali: "", tur: "Puan türünü seç.", sira: `${turEtiketi} başarı sıralamanı yaz.`, bilet: `Özet: ${siraDeger.toLocaleString("tr-TR")} · ${turEtiketi}. ${profil ? "Kaydet" : "Tercihlere geç"}.`, kaydedildi: `Sıralaman güncellendi: ${siraDeger.toLocaleString("tr-TR")} · ${turEtiketi}.`, }; return ( {adim === "kapali" ? (
{profil ? ( ) : ( <> Nasıl çalışır? )} {araButonu}
) : null} {adim === "tur" ? (
Puan türün
) : null} {adim === "sira" ? (
{/* Hap kabının içinde kenarlıksız ham alan (DESIGN.md §4.6 istisnası): hapın kendisi form alanıdır, ikinci bir hap çizilmez. */} · {turEtiketi}
{hata ? ( ) : null}
) : null} {adim === "bilet" ? (

Başarı sıralaman

{siraDeger.toLocaleString("tr-TR")}

Puan türü
{turEtiketi}
Tercihler
{profil ? `${profil.secimler.kategoriler.length} alan · ${ profil.secimler.iller.length ? `${profil.secimler.iller.length} il` : "her il" }` : "Sıradaki adım"}
{profil ? ( ) : null}
) : null} {adim === "kaydedildi" ? (
Sıralaman güncellendi {siraDeger.toLocaleString("tr-TR")} · {turEtiketi} {/* Kapatma çarpısı DialogContent varsayılanıyla aynı sınıf seti (§4.3). */}
) : null}
); } ``` ## 5. Bağlantı yaması (nav, kapı deposu, kapı modalı, DESIGN.md §4.33–4.34) `git apply` ile uygulanır (HEAD `bc920f6` tabanlı; çakışırsa hunk'lar elle taşınır). DESIGN.md bölümleri §6'da. ```diff diff --git a/src/features/katalog/components/katalog-arama.tsx b/src/features/katalog/components/katalog-arama.tsx index 35aad40..64265bd 100644 --- a/src/features/katalog/components/katalog-arama.tsx +++ b/src/features/katalog/components/katalog-arama.tsx @@ -25,6 +25,7 @@ import { } from "@/features/liste/hooks/use-tercih-profili"; import { preloadTercihProfiliKapisi } from "@/features/liste/components/tercih-profili-kapisi-lazy"; import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler"; +import { SiralamaHapi } from "@/features/sihirbaz/components/siralama-hapi"; import { olay } from "@/lib/analitik"; import { ArrowRight, @@ -266,69 +267,31 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { return ( <> - {/* lg altında logo + sağ küme ile çakışır (absolute); o aralıkta gizli */} -