Files
kolaytercih/docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md
bilalgursen 3c995402c6 feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00

1027 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<HTMLDivElement>(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<number | "auto">("auto");
const yukseklik = useMotionValue<number | "auto">("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<HTMLDivElement>) {
if (olay.key !== "Escape" || !onGeri || !acik) return;
olay.preventDefault();
olay.stopPropagation();
onGeri();
}
return (
<div className={cn("relative", className)}>
<motion.div
ref={cerceveRef}
role="group"
aria-label={etiket}
onKeyDown={tusBasildi}
style={{ width: genislik, height: yukseklik }}
className={cn(
"relative overflow-clip rounded-3xl border border-slate-200 bg-card transition-shadow duration-300",
acik && "shadow-[0_24px_48px_-20px_rgba(15,23,42,0.3)]",
)}
>
<AnimatePresence initial={false} custom={yon}>
<Yuz
key={adim}
dogal={!olculu && adim === ilkAdim}
azalt={Boolean(azalt)}
onGecis={gecisBaslat}
onOlcu={hedefeGit}
>
{children}
</Yuz>
</AnimatePresence>
</motion.div>
<p className="sr-only" aria-live="polite">
{duyuru}
</p>
</div>
);
}
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<HTMLDivElement>(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<HTMLElement>("[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 (
<motion.div
ref={ref}
variants={azalt ? YUZ_VARYANT_AZALT : YUZ_VARYANT}
initial="giris"
animate="orta"
exit="cikis"
className={cn("w-max", dogal ? "relative" : "absolute top-0 left-0")}
>
{children}
</motion.div>
);
}
```
## 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<Adim, number> = {
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<Adim>("kapali");
const [yon, setYon] = useState<1 | -1>(1);
const [tur, setTur] = useState<PuanTuru>("say");
const [siralama, setSiralama] = useState("");
const [hata, setHata] = useState<string | null>(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<Adim, string> = {
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 (
<SekilHapi
adim={adim}
ilkAdim="kapali"
yon={yon}
duyuru={duyuru[adim]}
onGeri={geri}
onDisariTiklama={kapat}
etiket="Sıralama ve arama"
>
{adim === "kapali" ? (
<div className="flex items-center gap-1 p-1.5 text-base font-medium text-slate-600">
{profil ? (
<button
type="button"
onClick={ac}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pr-1.5 pl-4 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"
>
<Trophy className="size-4 shrink-0 text-orange-500" aria-hidden />
<span className="font-semibold tracking-tight text-slate-900">
<span className="tabular-nums">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="font-medium text-slate-500">
{" "}
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</span>
<span
aria-hidden
className="flex max-w-0 items-center gap-1 overflow-hidden rounded-full bg-slate-100 px-0 py-1.5 text-xs font-medium text-slate-500 opacity-0 transition-[max-width,padding,opacity,background-color,color] duration-200 group-hover/profil:max-w-28 group-hover/profil:bg-card group-hover/profil:px-2.5 group-hover/profil:text-slate-900 group-hover/profil:opacity-100 group-focus-visible/profil:max-w-28 group-focus-visible/profil:bg-card group-focus-visible/profil:px-2.5 group-focus-visible/profil:text-slate-900 group-focus-visible/profil:opacity-100"
>
<SquarePen className="size-3 shrink-0" aria-hidden />
Değiştir
</span>
</button>
) : (
<>
<button
type="button"
onClick={ac}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
<Trophy className="size-4 text-orange-500" aria-hidden />
Sıralamanı gir
</button>
<Link
href="/#nasil-calisir"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Nasıl çalışır?
</Link>
</>
)}
{araButonu}
</div>
) : null}
{adim === "tur" ? (
<div className="flex items-center gap-2 p-1.5">
<button type="button" onClick={kapat} aria-label="Kapat" className={GERI}>
<ArrowLeft className="size-4" aria-hidden />
</button>
<span className="pl-1 text-sm font-semibold text-slate-700">
Puan türün
</span>
<SecimHaplari
ariaLabel="Puan türü"
secenekler={PUAN_TURLERI_SECENEK}
deger={tur}
onChange={setTur}
className="flex-nowrap"
/>
<button
type="button"
onClick={() => adimaGec("sira")}
aria-label="İleri, sıralamanı yaz"
data-odak
className={ILERI}
>
<ArrowRight className="size-4" aria-hidden />
</button>
</div>
) : null}
{adim === "sira" ? (
<form onSubmit={siraGonder} className="flex flex-col gap-1 p-1.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={geri}
aria-label="Geri, puan türü"
className={GERI}
>
<ArrowLeft className="size-4" aria-hidden />
</button>
{/* 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. */}
<label className="relative flex items-center">
<Trophy
className="pointer-events-none absolute left-3 size-4 text-orange-500"
aria-hidden
/>
<span className="sr-only">{turEtiketi} başarı sıralaman</span>
<input
data-odak
inputMode="numeric"
autoComplete="off"
placeholder="ör. 85.000"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "hap-siralama-hata" : undefined}
className="h-9 w-44 min-w-0 bg-transparent pl-10 text-base font-semibold tracking-tight text-slate-900 tabular-nums outline-none placeholder:font-medium placeholder:text-slate-400"
/>
</label>
<span className="pr-1 text-sm font-medium text-slate-500">
· {turEtiketi}
</span>
<button
type="submit"
aria-label="İleri, özeti gör"
className={ILERI}
>
<ArrowRight className="size-4" aria-hidden />
</button>
</div>
{hata ? (
<p
id="hap-siralama-hata"
role="alert"
className="px-3 pb-1.5 text-sm font-medium text-red-600"
>
{hata}
</p>
) : null}
</form>
) : null}
{adim === "bilet" ? (
<div className="w-80">
<div className="flex flex-col gap-3 px-5 pt-4 pb-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-500">
Başarı sıralaman
</p>
<p className="font-heading text-3xl font-bold tracking-tight text-slate-900">
{siraDeger.toLocaleString("tr-TR")}
</p>
</div>
<Trophy className="mt-1 size-5 text-orange-500" aria-hidden />
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-slate-500">Puan türü</dt>
<dd className="font-semibold text-slate-900">{turEtiketi}</dd>
</div>
<div>
<dt className="text-slate-500">Tercihler</dt>
<dd className="font-semibold text-slate-900">
{profil
? `${profil.secimler.kategoriler.length} alan · ${
profil.secimler.iller.length
? `${profil.secimler.iller.length} il`
: "her il"
}`
: "Sıradaki adım"}
</dd>
</div>
</dl>
{profil ? (
<button
type="button"
onClick={tercihleriDeDegistir}
className="cursor-pointer self-start text-sm font-medium text-slate-600 underline-offset-4 transition-colors duration-150 hover:text-orange-500 hover:underline"
>
Alan ve şehir tercihlerini de değiştir
</button>
) : null}
</div>
<div className="flex items-center gap-2 border-t border-dashed border-slate-200 p-1.5">
<button
type="button"
onClick={geri}
aria-label="Geri, sıralamayı değiştir"
className={GERI}
>
<ArrowLeft className="size-4" aria-hidden />
</button>
<Button
type="button"
data-odak
onClick={profil ? kaydet : tercihlereDevam}
className="flex-1 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{profil ? "Kaydet" : "Tercihlere geç"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
</div>
) : null}
{adim === "kaydedildi" ? (
<div className="flex items-center gap-3 py-1.5 pr-1.5 pl-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-green-600 text-white">
<Check className="size-4" aria-hidden />
</span>
<span className="grid leading-tight">
<span className="text-sm font-semibold text-slate-900">
Sıralaman güncellendi
</span>
<span className="text-xs text-slate-500 tabular-nums">
{siraDeger.toLocaleString("tr-TR")} · {turEtiketi}
</span>
</span>
{/* Kapatma çarpısı DialogContent varsayılanıyla aynı sınıf seti (§4.3). */}
<button
type="button"
onClick={kapat}
data-odak
className="flex size-9 shrink-0 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>
</button>
</div>
) : null}
</SekilHapi>
);
}
```
## 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 */}
- <nav className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-card p-1.5 text-base font-medium text-slate-600 lg:flex">
- {profil ? (
- <button
- type="button"
- onClick={profilDuzenlemeyiAc}
- onPointerEnter={preloadTercihProfiliKapisi}
- onFocus={preloadTercihProfiliKapisi}
- aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
- className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"
- >
- <Trophy className="size-4 shrink-0 text-orange-500" aria-hidden />
- <span className="font-semibold tracking-tight text-slate-900">
- <span className="tabular-nums">
- {profil.sira.toLocaleString("tr-TR")}
- </span>
- <span className="font-medium text-slate-500">
- {" "}
- · {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
- </span>
- </span>
- <span
- aria-hidden
- className="flex max-w-0 items-center gap-1 overflow-hidden rounded-full bg-slate-100 px-0 py-1.5 text-xs font-medium text-slate-500 opacity-0 transition-[max-width,padding,opacity,background-color,color] duration-200 group-hover/profil:max-w-28 group-hover/profil:bg-card group-hover/profil:px-2.5 group-hover/profil:text-slate-900 group-hover/profil:opacity-100 group-focus-visible/profil:max-w-28 group-focus-visible/profil:bg-card group-focus-visible/profil:px-2.5 group-focus-visible/profil:text-slate-900 group-focus-visible/profil:opacity-100"
- >
- <SquarePen className="size-3 shrink-0" aria-hidden />
- Değiştir
- </span>
- </button>
- ) : (
- <>
+ {/* lg altında logo + sağ küme ile çakışır (absolute); o aralıkta gizli.
+ Hapın kendisi SiralamaHapi: kapalıyken sıralama + Nasıl çalışır + Ara,
+ dokununca adım adım yeniden biçimlenir (DESIGN.md §4.34). */}
+ <nav
+ aria-label="Sıralama ve arama"
+ className="absolute left-1/2 hidden -translate-x-1/2 lg:block"
+ >
+ <SiralamaHapi
+ araButonu={
<button
type="button"
- onClick={siralamaGirVeyaSonucaGit}
- onPointerEnter={preloadTercihProfiliKapisi}
- onFocus={preloadTercihProfiliKapisi}
- className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
+ onClick={() => modalDegisti(true)}
+ className="flex cursor-pointer items-center gap-2 rounded-full bg-slate-900 px-5 py-2 text-slate-50 transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
>
- <Trophy className="size-4 text-orange-500" aria-hidden />
- Sıralamanı gir
+ <Search className="size-4" aria-hidden />
+ Ara
+ <KbdGroup aria-hidden className="ml-1">
+ <Kbd className="bg-slate-50/15 text-slate-50/85">
+ {macMi ? "⌘" : "Ctrl"}
+ </Kbd>
+ <Kbd className="bg-slate-50/15 text-slate-50/85">K</Kbd>
+ </KbdGroup>
</button>
- <Link
- href="/#nasil-calisir"
- className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
- >
- Nasıl çalışır?
- </Link>
- </>
- )}
- <button
- type="button"
- onClick={() => modalDegisti(true)}
- className="flex cursor-pointer items-center gap-2 rounded-full bg-slate-900 px-5 py-2 text-slate-50 transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
- >
- <Search className="size-4" aria-hidden />
- Ara
- <KbdGroup aria-hidden className="ml-1">
- <Kbd className="bg-slate-50/15 text-slate-50/85">
- {macMi ? "⌘" : "Ctrl"}
- </Kbd>
- <Kbd className="bg-slate-50/15 text-slate-50/85">K</Kbd>
- </KbdGroup>
- </button>
+ }
+ />
</nav>
{/* lg altında 2. satır: sıralama + ara (header flex-wrap + order-3) */}
diff --git a/src/features/liste/components/tercih-profili-kapisi.tsx b/src/features/liste/components/tercih-profili-kapisi.tsx
index f8584c1..340d148 100644
--- a/src/features/liste/components/tercih-profili-kapisi.tsx
+++ b/src/features/liste/components/tercih-profili-kapisi.tsx
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight, Trophy } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -61,12 +61,13 @@ export function TercihProfiliKapisi() {
bekleyen,
bekleyenHedef,
profil,
+ onSira,
} = useProfilKapisi();
// Kapanış olayı için o an görünen adım; KapıIcerik her adım değişiminde
// (ve mount'ta) bildirir. Render'ı etkilemediği için state değil ref.
const adimRef = useRef<Adim>("giris");
const oturumAnahtari = acik
- ? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}`
+ ? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}-${onSira?.sira ?? 0}-${onSira?.tur ?? ""}`
: "kapali";
// Tamamlanmadan kapanışın TEK noktası: X, dışarı tıklama, Esc (Dialog
@@ -106,6 +107,7 @@ export function TercihProfiliKapisi() {
bekleyen={bekleyen}
hedef={bekleyenHedef}
profil={profil}
+ onSira={onSira}
onAdimDegisti={(yeniAdim) => {
adimRef.current = yeniAdim;
}}
@@ -123,6 +125,7 @@ function KapıIcerik({
bekleyen,
hedef,
profil,
+ onSira,
onAdimDegisti,
onVazgec,
}: {
@@ -131,14 +134,16 @@ function KapıIcerik({
bekleyen: BekleyenProgram | null;
hedef: BekleyenHedef | null;
profil: TercihProfili | null;
+ /** Üst çubuk hapından hazır sıra+tür: açılışta facetler yüklenir, sıra adımı atlanır. */
+ onSira: { sira: number; tur: string } | null;
/** Görünen adımı üst bileşene bildirir (yalnız `kapi_kapatildi` ölçümü). */
onAdimDegisti: (adim: Adim) => void;
/** Tamamlamadan kapat — olayı üst bileşen atar, sonra kapıyı kapatır. */
onVazgec: () => void;
}) {
const router = useRouter();
- const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
- const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
+ const bilinenSira = bekleyen?.adaySira ?? onSira?.sira ?? profil?.sira ?? null;
+ const bilinenTur = bekleyen?.adayTur ?? onSira?.tur ?? profil?.tur ?? "say";
// Düzenlemede ve navbar "Sıralamanı gir" girişinde doğrudan sıralama adımı
const ilkAdim: Adim = duzenle || dogrudanSira ? "sira" : "giris";
@@ -147,6 +152,9 @@ function KapıIcerik({
// önceki açılıştan kalan "sihirbaz" değeri yeni kapanışa sızmasın.
useEffect(() => {
onAdimDegisti(ilkAdim);
+ // Sıra hapından gelindi: sıra adımı görünür görünmez facetler yüklenir ve
+ // alan/il adımına geçilir; kullanıcı sırayı ikinci kez yazmaz.
+ if (onSira) void facetYukle(onSira.sira, onSira.tur);
// eslint-disable-next-line react-hooks/exhaustive-deps -- yalnız mount
}, []);
// Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar
diff --git a/src/features/liste/hooks/use-tercih-profili.ts b/src/features/liste/hooks/use-tercih-profili.ts
index 0f543e2..f65fb0d 100644
--- a/src/features/liste/hooks/use-tercih-profili.ts
+++ b/src/features/liste/hooks/use-tercih-profili.ts
@@ -70,6 +70,9 @@ type KapıDurumu = {
bekleyenHedef: BekleyenHedef | null;
profil: TercihProfili | null;
profilYuklendi: boolean;
+ /** Üst çubuktaki sıralama hapından hazır gelen sıra+tür: kapı facetleri
+ * yükleyip sıra adımını atlar, doğrudan alan/il adımıyla açılır. */
+ onSira: { sira: number; tur: string } | null;
};
let durum: KapıDurumu = {
@@ -81,6 +84,7 @@ let durum: KapıDurumu = {
bekleyenHedef: null,
profil: null,
profilYuklendi: false,
+ onSira: null,
};
const dinleyiciler = new Set<() => void>();
@@ -133,6 +137,7 @@ const SSR_DURUM: KapıDurumu = {
bekleyenHedef: null,
profil: null,
profilYuklendi: false,
+ onSira: null,
};
export function useProfilKapisi(): KapıDurumu {
@@ -159,6 +164,7 @@ export function profilKapisiniKapat() {
tetik: "diger",
bekleyen: null,
bekleyenHedef: null,
+ onSira: null,
};
yayinla();
}
@@ -170,10 +176,11 @@ function kapiyiAc(
yama: Pick<
KapıDurumu,
"duzenle" | "dogrudanSira" | "bekleyen" | "bekleyenHedef"
- >,
+ > &
+ Partial<Pick<KapıDurumu, "onSira">>,
tetik: KapiTetik,
) {
- durum = { ...durum, acik: true, ...yama, tetik };
+ durum = { ...durum, acik: true, onSira: null, ...yama, tetik };
yayinla();
olay("kapi_acildi", { tetik });
}
@@ -215,6 +222,25 @@ export function profilToplamaAc(
);
}
+/**
+ * Üst çubuktaki sıralama hapı (siralama-hapi.tsx) sıra+türü aldı: kapı bu
+ * değerlerle facetleri yükler ve doğrudan alan/il adımıyla açılır; sıra
+ * adımı tekrar sorulmaz. Tamamlanınca /sonuc'a gider.
+ */
+export function tercihlereGec(onSira: { sira: number; tur: string }) {
+ if (!durum.profilYuklendi) yukle();
+ kapiyiAc(
+ {
+ duzenle: false,
+ dogrudanSira: true,
+ bekleyen: null,
+ bekleyenHedef: "sonuc",
+ onSira,
+ },
+ "sonuc",
+ );
+}
+
/**
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
@@ -337,6 +363,7 @@ export function profilTamamlandi(
tetik: "diger",
bekleyen: null,
bekleyenHedef: null,
+ onSira: null,
profil,
profilYuklendi: true,
};
```
## 6. DESIGN.md'ye eklenecek bölümler (kaldırıldı)
§4.6 ham alan istisnası listesine "üst çubuk sıralama hapının sıralama adımı (`siralama-hapi.tsx`, §4.34)" eklenir; §4.33–4.34 metni:
### 4.33 Şekil değiştiren hap — `SekilHapi`, `src/components/ui/sekil-hapi.tsx`
Tek bir hap yüzeyi adımdan adıma **yeniden biçimlenir**: çerçeve (genişlik + yükseklik) yeni
yüzün ölçüsüne `YAY.donusum` ile yaylanır, çıkan yüz 0,12 s'de söner (opaklık 0, %97 ölçek,
16 px geriye, 4 px blur), giren yüz 90 ms sonra karşı yönden aynı yoldan gelir. Yalnız o anki
yüz monte; yüzün içi büyüyüp küçülürse (hata satırı) çerçeve onu da izler. Yüzey
`rounded-3xl border-slate-200 bg-card`; kapalı 48 px hap tam yuvarlak kalır, gölge
(`shadow-[0_24px_48px_-20px_rgba(15,23,42,0.3)]`) yalnız açıkken. Escape bir adım geri, hap
dışına basmak kapatır, adım değişimi `aria-live` ile okunur, `data-odak` taşıyan öğe yüz gelince
odaklanır. İlk boyama doğal akıştadır (SSR'da görünmez kalmaz); kapalı adıma dönüşte doğal akışa
geri döner. Hareketi azalt'ta ölçü zıplar, yüzler yalnız opaklıkla değişir. Kaynak: Arc UI
"Booking pill" (Pro; kodu alınmadı, 7 Eki 2026'da canlı önizlemeden ölçüldü).
### 4.34 Sıralama hapı (üst çubuk, lg+) — `SiralamaHapi`, `src/features/sihirbaz/components/siralama-hapi.tsx`
Üst çubuğun ortasındaki gezinme hapı §4.33'ün ilk kullanımı. Kapalı yüz eski nav ile birebir:
kayıtlı sıra özeti (kupa + `45.000 · Sayısal`, hover'da "Değiştir" çipi) ya da "Sıralamanı gir" +
"Nasıl çalışır?", sonda koyu "Ara ⌘K" hapı. Dokununca aynı hap adım adım açılır:
1. **Puan türü** — `SecimHaplari` (ayrık, mavi vurgu kayar), solda geri (`bg-slate-100`), sağda
ileri (`bg-slate-900 text-slate-50`) yuvarlak `size-9` butonlar.
2. **Sıralama** — hapın içinde kenarlıksız ham alan (§4.6 istisnası; hap zaten form alanıdır),
kupa turuncu, sağında "· Sayısal"; hata `role="alert"` satırı altına açılır, hap uzar.
3. **Bilet** — `w-80`: "Başarı sıralaman" + iri `font-heading text-3xl` sayı
(ikon sağda küçük, §7), altında Puan türü / Tercihler çifti, kesik çizgili koparma hattı,
koçanda geri + turuncu CTA (metin + sondaki `ArrowRight`, §4.1).
- Profil yoksa CTA "Tercihlere geç": hap kapanır, tercih profili kapısı sıra+türü hazır alıp
facetleri yükler ve doğrudan alan/il adımıyla açılır (`tercihlereGec`). Sıra iki kez sorulmaz.
- Profil varsa CTA "Kaydet": sıra+tür yerinde yazılır, alan/il seçimleri korunur, riskler
yenilenir; "Alan ve şehir tercihlerini de değiştir" bağlantısı eski modalı açar.
4. **Kaydedildi** — yeşil onay diski + "Sıralaman güncellendi" + özet; 2 s sonra kendiliğinden
kapanır, çarpı §4.3 kanonik seti.
lg altındaki ikinci satır bu hapı kullanmaz; orada sıralama hâlâ modalla girilir.