diff --git a/data/app.db b/data/app.db index 9c154e8..43b41cf 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md b/docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md new file mode 100644 index 0000000..919e6b6 --- /dev/null +++ b/docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md @@ -0,0 +1,1026 @@ +# 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 */} +-