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

42 KiB
Raw Blame History

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

"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

"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 --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.