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>
1027 lines
42 KiB
Markdown
1027 lines
42 KiB
Markdown
# 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.
|
||
|