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>
42 KiB
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ölge0 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, saatlerradiogroup, adım değişimiaria-live=polite, hatarole=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çıkkenshadow-[0_24px_48px_-20px_rgba(15,23,42,0.3)], camsı blur yok. Yaylarsrc/lib/hareket.ts: çerçeveYAY.donusum, çıkışSURE.anlik, girişSURE.standartEGRI.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 ankioffsetWidth/Height'a kilitlenir (jump), yüzler mutlak konuma geçer; kapalıya dönüp yay bitince tekrarauto(duyarlı yerleşim). ÖlçüoffsetWidthile (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ı
onSiraile 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,profilTamamlandiriskleri 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_toolkoşarken pane arka planda sayılır, rAF durur; motion animasyonları JS içindeki bekleyişle ilerlemez. Zamanlama içincomputer.waitkullan.
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:
- Puan türü —
SecimHaplari(ayrık, mavi vurgu kayar), solda geri (bg-slate-100), sağda ileri (bg-slate-900 text-slate-50) yuvarlaksize-9butonlar. - 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. - Bilet —
w-80: "Başarı sıralaman" + irifont-heading text-3xlsayı (ikon sağda küçük, §7), altında Puan türü / Tercihler çifti, kesik çizgili koparma hattı, koçanda geri + turuncu CTA (metin + sondakiArrowRight, §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.
- 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 (
- 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.