diff --git a/data/app.db b/data/app.db
index 9c154e8..43b41cf 100644
Binary files a/data/app.db and b/data/app.db differ
diff --git a/docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md b/docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md
new file mode 100644
index 0000000..919e6b6
--- /dev/null
+++ b/docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md
@@ -0,0 +1,1026 @@
+# Booking pill → şekil değiştiren hap (SekilHapi) — kaldırılan uygulama, saklanan mantık
+
+7 Eki 2026. Bilal Arc UI "Booking pill" (https://uiarc.dev/components/booking-pill, **Pro**)
+davranışını üst çubuk hapına istedi; uygulandı, tarayıcıda doğrulandı, sonra Bilal'in kararıyla
+koddan geri alındı. Bu belge mantığı ve kodu saklar; yeniden açmak için aşağıdaki iki dosya
+`src/` altına koyulur ve en sondaki yama uygulanır (`git apply`).
+
+## 1. Demodan ölçülen davranış (Chrome, DOM + computedStyle + rAF örnekleme)
+
+Kaynak kodu Pro'ya kilitli, alınmadı; canlı önizleme adım adım tıklanıp ölçüldü.
+
+| Adım | Çerçeve (px) | İçerik |
+|---|---|---|
+| kapalı | 164×52 | takvim ikonu + "Book a table" |
+| kişi | 300×60 | geri (44) · −/sayı rulosu/+ (36) · ileri (44), padding 8, gap 6 |
+| tarih | 420×124 | bar (geri · başlık · ileri) + sürüklenen gün şeridi (slider, pan-y) |
+| saat | 360×262 | başlık + alt satır · 4 sütun 81×40 radyo (kayan vurgu) · tam genişlik CTA 44 |
+| bilet | 340×215 | mekân adı 22/500, adres 14, dl Date/Time/Guests, kesik koparma hattı, koçanda geri + CTA |
+| rezerve | 244×56 | yeşil onay diski 32 + iki satır metin + yenile butonu 36 |
+
+- Yüzey: `rgba koyu /0.8` + `backdrop blur(24px) saturate(1.6)`, köşe sabit 30 px, iç 1 px halka,
+ dış gölge `0 22px 55px rgba(0,0,0,.34)`. Yalnız o anki yüz monte; her yüz ResizeObserver'lı.
+- Geçiş (örnekleme, ms): çıkan yüz ~100 ms'de opaklık 1→0, ölçek 1→0,97, x 0→−10..−16 (gidiş
+ yönünün tersi), blur 0→4 px. Giren yüz ~90 ms gecikmeyle opaklık 0→1, ölçek 0,95→1, x +16→0,
+ blur 4→0 (~250 ms). Çerçeve genişlik/yükseklik sekmesiz yayla ~400 ms'de yeni ölçüye oturur
+ (340×215 → 240×50; eşzamanlı, içerik bir vuruş geriden).
+- Klavye: ok tuşları şeritte gün / ızgarada saat, PgUp/PgDn hafta, Home/End, Enter onay,
+ Escape bir adım geri. Erişilebilirlik: şerit `role=slider` + `aria-valuetext`, saatler
+ `radiogroup`, adım değişimi `aria-live=polite`, hata `role=alert`.
+
+## 2. Bizdeki tasarım kararları
+
+- Genel hap mekaniği (`SekilHapi`) ile akış (`SiralamaHapi`) ayrıldı.
+- Yüzey DESIGN.md dili: `rounded-3xl border-slate-200 bg-card` (kapalı 48 px hap tam yuvarlak),
+ gölge yalnız açıkken `shadow-[0_24px_48px_-20px_rgba(15,23,42,0.3)]`, camsı blur yok.
+ Yaylar `src/lib/hareket.ts`: çerçeve `YAY.donusum`, çıkış `SURE.anlik`, giriş `SURE.standart`
+ + `EGRI.giris`, vuruş 90 ms, kayma 16 px. Hareketi azalt: ölçü zıplar, yalnız opaklık.
+- SSR tuzağı: çerçeve ilk boyamada `auto` (doğal akış), yoksa hidrasyon öncesi nav görünmez
+ kalırdı. İlk adım değişiminde çerçeve o anki `offsetWidth/Height`'a kilitlenir (`jump`),
+ yüzler mutlak konuma geçer; kapalıya dönüp yay bitince tekrar `auto` (duyarlı yerleşim).
+ Ölçü `offsetWidth` ile (getBoundingClientRect dönüşümü içerir, giren yüz %95 ölçekte).
+- Akış eşlemesi: kişi → **puan türü** (SecimHaplari ayrık), tarih → **sıralama** (hapın içinde
+ kenarlıksız ham input, §4.6 istisnası), saat → yok, bilet → **bilet** (iri sayı + puan türü /
+ tercihler + koparma hattı + turuncu CTA), rezerve → **kaydedildi** (2 s sonra kendiliğinden
+ kapanır).
+- Profil yoksa bilet "Tercihlere geç": hap kapanır, kapı modalı `onSira` ile açılır, facetleri
+ yükler ve doğrudan alan/il adımına geçer (sıra iki kez sorulmaz). Profil varsa "Kaydet": sıra+tür
+ yerinde yazılır, alan/il korunur, `profilTamamlandi` riskleri yeniler, `router.refresh()`.
+- lg altı satır modal yolunda kaldı. Sihirbaz adımları değişmediği için meraklısına şeması
+ güncellenmedi.
+- Doğrulama (localhost:3000, 1280 px, açık+koyu): 466→519→351→(hata 345×80)→322×191 geçişleri,
+ modalın Adım 1/3'te açılması, Kaydet → kaydedildi → 2 s kapanış çalıştı. tsc/lint/denetim temiz.
+- Tarayıcı notu: `javascript_tool` koşarken pane arka planda sayılır, rAF durur; motion
+ animasyonları JS içindeki bekleyişle ilerlemez. Zamanlama için `computer.wait` kullan.
+
+## 3. Kod — `src/components/ui/sekil-hapi.tsx`
+
+```tsx
+"use client";
+
+// Şekil değiştiren hap: tek bir hap yüzeyi, içindeki adıma göre yayla
+// yeniden biçimlenir; içerik bir vuruş sonra gidiş yönünde kayarak gelir.
+//
+// Kaynak: Arc UI "Booking pill" (uiarc.dev, Pro — kodu alınmadı; 7 Eki 2026'da
+// canlı önizlemeden ölçüldü). Oradaki ölçüm: çerçeve genişlik/yükseklik
+// ~0,4 s'lik sekmesiz yayla yeni yüzün ölçüsüne gider; çıkan yüz ~0,1 s'de
+// söner (opaklık 0, %97 ölçek, 16 px geriye, 4 px blur), giren yüz ~90 ms
+// gecikmeyle karşı taraftan aynı yoldan gelir; yalnız o anki yüz monte, çıkan
+// yüz çıkış süresince üstte kalır. Bizdeki farklar: yay `YAY.donusum`, süreler
+// `SURE`, eğri `EGRI` (src/lib/hareket.ts); koyu camsı yüzey yerine
+// `border-slate-200 bg-card` hapı; 30 px köşe yerine `rounded-3xl` (kapalı
+// 48 px hap tam yuvarlak kalır); gölge yalnız açıkken. Hareketi azalt'ta ölçü
+// zıplar, yüzler yalnız opaklıkla değişir.
+//
+// Çalışma biçimi: ilk boyamada (SSR dâhil) hap doğal akışta ölçülür, çerçeve
+// `auto`'dur — hidrasyon öncesi görünmez kalmaz. İlk adım değişiminde çerçeve
+// o anki ölçüsüne kilitlenir, yüzler mutlak konuma geçer ve çerçeve yeni
+// yüzün ölçüsüne yaylanır. Açık yüz büyüyüp küçülürse (hata satırı) çerçeve
+// onu da izler (ResizeObserver). İlk adıma dönülüp hareket bitince doğal
+// akışa geri döner ki üst çubuk duyarlı yerleşimini korusun.
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type ReactNode,
+} from "react";
+import {
+ AnimatePresence,
+ animate,
+ motion,
+ useMotionValue,
+ useReducedMotion,
+ type Variants,
+} from "motion/react";
+import { cn } from "@/lib/utils";
+import { EGRI, SURE, YAY } from "@/lib/hareket";
+
+/** Yüzün gidiş yönünde kaydığı mesafe (px). */
+const KAYMA = 16;
+/** Giren yüzün çerçeveden sonra başladığı vuruş (s). */
+const VURUS = 0.09;
+
+export interface SekilHapiProps {
+ /** Görünen adımın anahtarı; değişince yüz değişir. */
+ adim: string;
+ /** Kapalı adım: burada çerçeve doğal akışa döner, dış tıklama dinlenmez. */
+ ilkAdim: string;
+ /** +1 ileri, -1 geri — içerik bu yönde kayar. */
+ yon: 1 | -1;
+ /** Ekran okuyucuya adım değişimini söyleyen cümle. */
+ duyuru?: string;
+ /** Escape: bir adım geri. */
+ onGeri?: () => void;
+ /** Açıkken hap dışına basılınca. */
+ onDisariTiklama?: () => void;
+ /** Hapın ekran okuyucu adı. */
+ etiket: string;
+ className?: string;
+ /** O anki adımın yüzü; `data-odak` taşıyan öğe yüz gelince odaklanır. */
+ children: ReactNode;
+}
+
+export function SekilHapi({
+ adim,
+ ilkAdim,
+ yon,
+ duyuru,
+ onGeri,
+ onDisariTiklama,
+ etiket,
+ className,
+ children,
+}: SekilHapiProps) {
+ const azalt = useReducedMotion();
+ const cerceveRef = useRef(null);
+ // Doğal akış (false) ↔ ölçülü çerçeve (true). Ref, efektlerin bayat
+ // kapanış okumaması için state'in aynası.
+ const [olculu, setOlculu] = useState(false);
+ const olculuRef = useRef(false);
+ const adimRef = useRef(adim);
+ useLayoutEffect(() => {
+ adimRef.current = adim;
+ }, [adim]);
+ const genislik = useMotionValue("auto");
+ const yukseklik = useMotionValue("auto");
+ const acik = adim !== ilkAdim;
+
+ /** Çerçeveyi (kenarlık dâhil) yüz ölçüsüne götürür. */
+ const hedefeGit = useCallback(
+ (w: number, h: number) => {
+ const cerceve = cerceveRef.current;
+ if (!cerceve || !olculuRef.current) return;
+ const ekW = cerceve.offsetWidth - cerceve.clientWidth;
+ const ekH = cerceve.offsetHeight - cerceve.clientHeight;
+ const gecis = azalt ? { duration: 0 } : YAY.donusum;
+ const hareket = Promise.all([
+ animate(genislik, w + ekW, gecis),
+ animate(yukseklik, h + ekH, gecis),
+ ]);
+ void hareket.then(() => {
+ // Kapalı adıma dönüş bitti: doğal akışa geri (duyarlı yerleşim).
+ if (adimRef.current !== ilkAdim || !olculuRef.current) return;
+ olculuRef.current = false;
+ genislik.set("auto");
+ yukseklik.set("auto");
+ setOlculu(false);
+ });
+ },
+ [azalt, genislik, yukseklik, ilkAdim],
+ );
+
+ /** Yeni yüz monte oldu: çerçeveyi olduğu ölçüye kilitle, sonra hedefe yaylan. */
+ const gecisBaslat = useCallback(
+ (w: number, h: number) => {
+ const cerceve = cerceveRef.current;
+ if (!cerceve) return;
+ if (!olculuRef.current) {
+ olculuRef.current = true;
+ genislik.jump(cerceve.offsetWidth);
+ yukseklik.jump(cerceve.offsetHeight);
+ setOlculu(true);
+ }
+ hedefeGit(w, h);
+ },
+ [genislik, yukseklik, hedefeGit],
+ );
+
+ useEffect(() => {
+ if (!acik || !onDisariTiklama) return;
+ function dinle(olay: PointerEvent) {
+ const cerceve = cerceveRef.current;
+ if (cerceve && !cerceve.contains(olay.target as Node)) onDisariTiklama?.();
+ }
+ document.addEventListener("pointerdown", dinle);
+ return () => document.removeEventListener("pointerdown", dinle);
+ }, [acik, onDisariTiklama]);
+
+ function tusBasildi(olay: KeyboardEvent) {
+ if (olay.key !== "Escape" || !onGeri || !acik) return;
+ olay.preventDefault();
+ olay.stopPropagation();
+ onGeri();
+ }
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ {duyuru}
+
+
+ );
+}
+
+const YUZ_VARYANT: Variants = {
+ giris: (yon: 1 | -1) => ({
+ opacity: 0,
+ x: KAYMA * yon,
+ scale: 0.95,
+ filter: "blur(4px)",
+ }),
+ orta: {
+ opacity: 1,
+ x: 0,
+ scale: 1,
+ filter: "blur(0px)",
+ transition: { delay: VURUS, duration: SURE.standart, ease: EGRI.giris },
+ },
+ cikis: (yon: 1 | -1) => ({
+ opacity: 0,
+ x: -KAYMA * yon,
+ scale: 0.97,
+ filter: "blur(4px)",
+ transition: { duration: SURE.anlik, ease: EGRI.standart },
+ }),
+};
+
+const YUZ_VARYANT_AZALT: Variants = {
+ giris: { opacity: 0 },
+ orta: { opacity: 1, transition: { duration: SURE.hizli } },
+ cikis: { opacity: 0, transition: { duration: SURE.hizli } },
+};
+
+function Yuz({
+ dogal,
+ azalt,
+ onGecis,
+ onOlcu,
+ children,
+}: {
+ /** Doğal akıştaki ilk yüz: çerçeveyi kilitlemez, ölçü bildirmez. */
+ dogal: boolean;
+ azalt: boolean;
+ onGecis: (w: number, h: number) => void;
+ onOlcu: (w: number, h: number) => void;
+ children: ReactNode;
+}) {
+ const ref = useRef(null);
+
+ // Monte: ölç (dönüşümsüz offset ölçüsü), çerçeveyi başlat, odağı ver;
+ // sonra yüzün kendi boyut değişimlerini izle (hata satırı açılır vb.).
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ if (!dogal) onGecis(el.offsetWidth, el.offsetHeight);
+ el.querySelector("[data-odak]")?.focus({ preventScroll: true });
+ const gozlemci = new ResizeObserver(() => {
+ onOlcu(el.offsetWidth, el.offsetHeight);
+ });
+ gozlemci.observe(el);
+ return () => gozlemci.disconnect();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- yalnız mount
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+```
+
+## 4. Kod — `src/features/sihirbaz/components/siralama-hapi.tsx`
+
+```tsx
+"use client";
+
+// Üst çubuğun ortasındaki gezinme hapı (lg+): kapalıyken "Sıralamanı gir"
+// (ya da kayıtlı sıra özeti) + "Nasıl çalışır?" + Ara; dokununca AYNI hap
+// adım adım yeniden biçimlenir — puan türü → başarı sıralaması → bilet
+// (özet) → kaydedildi. Arc UI "Booking pill" akışının (kişi → tarih → saat →
+// bilet → rezerve) bizdeki karşılığı; hap mekaniği SekilHapi'de (DESIGN.md
+// §4.33), bu dosya yalnız yüzleri ve akışı bilir.
+//
+// İki yol:
+// - Profil yok: bilet "Tercihlere geç" der → hap kapanır, tercih profili
+// kapısı (modal) sıra+türü hazır alır, facetleri yükler ve doğrudan alan/il
+// adımıyla açılır (`tercihlereGec`). Sıra adımı modalda tekrar sorulmaz.
+// - Profil var: bilet "Kaydet" der → profil yerinde yazılır (alan/il
+// seçimleri korunur), riskler yenilenir, "kaydedildi" yüzü bir vuruş durup
+// kapanır. Alan/il de değişecekse bilet üstündeki bağlantı modalı açar.
+// lg altındaki ikinci satır bu hapı kullanmaz (modal yolu, katalog-arama.tsx).
+
+import { useEffect, useEffectEvent, useState, type ReactNode } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ ArrowLeft,
+ ArrowRight,
+ Check,
+ SquarePen,
+ Trophy,
+ X,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { SecimHaplari } from "@/components/ui/secim-haplari";
+import { SekilHapi } from "@/components/ui/sekil-hapi";
+import { preloadTercihProfiliKapisi } from "@/features/liste/components/tercih-profili-kapisi-lazy";
+import {
+ profilDuzenlemeyiAc,
+ profilTamamlandi,
+ tercihlereGec,
+ useTercihProfili,
+} from "@/features/liste/hooks/use-tercih-profili";
+import { PUAN_TURU_ETIKET, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
+import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
+import { olay, siraKovasi } from "@/lib/analitik";
+import { cn } from "@/lib/utils";
+
+const PUAN_TURLERI_SECENEK = [
+ { deger: "say", etiket: "Sayısal" },
+ { deger: "ea", etiket: "Eşit Ağırlık" },
+ { deger: "soz", etiket: "Sözel" },
+ { deger: "dil", etiket: "Dil" },
+ { deger: "tyt", etiket: "TYT" },
+] as const;
+type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"];
+
+function puanTuruMu(deger: string): deger is PuanTuru {
+ return PUAN_TURLERI_SECENEK.some((s) => s.deger === deger);
+}
+
+type Adim = "kapali" | "tur" | "sira" | "bilet" | "kaydedildi";
+const SIRA: Record = {
+ kapali: 0,
+ tur: 1,
+ sira: 2,
+ bilet: 3,
+ kaydedildi: 4,
+};
+
+/** Kaydedildi yüzünün ekranda kaldığı süre (ms); sonra hap kendiliğinden kapanır. */
+const KAYDEDILDI_BEKLEME = 2000;
+
+const YUVARLAK =
+ "flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full transition-[background-color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none active:scale-[0.96]";
+const GERI = cn(YUVARLAK, "bg-slate-100 text-slate-700 hover:bg-slate-200");
+const ILERI = cn(YUVARLAK, "bg-slate-900 text-slate-50 hover:bg-slate-700");
+
+export function SiralamaHapi({ araButonu }: { araButonu: ReactNode }) {
+ const router = useRouter();
+ const profil = useTercihProfili();
+ const [adim, setAdim] = useState("kapali");
+ const [yon, setYon] = useState<1 | -1>(1);
+ const [tur, setTur] = useState("say");
+ const [siralama, setSiralama] = useState("");
+ const [hata, setHata] = useState(null);
+ const siraDeger = Number(siralama.replace(/\./g, ""));
+ const turEtiketi = PUAN_TURU_ETIKET[tur] ?? tur;
+
+ function adimaGec(yeni: Adim) {
+ setYon(SIRA[yeni] > SIRA[adim] ? 1 : -1);
+ setAdim(yeni);
+ }
+
+ function ac() {
+ preloadTercihProfiliKapisi();
+ setHata(null);
+ setTur(profil && puanTuruMu(profil.tur) ? profil.tur : "say");
+ setSiralama(profil ? profil.sira.toLocaleString("tr-TR") : "");
+ adimaGec("tur");
+ }
+
+ function kapat() {
+ setHata(null);
+ adimaGec("kapali");
+ }
+
+ function geri() {
+ if (adim === "sira") adimaGec("tur");
+ else if (adim === "bilet") adimaGec("sira");
+ else kapat();
+ }
+
+ function siralamaDegisti(ham: string) {
+ setHata(null);
+ const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
+ setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
+ }
+
+ function siraGonder(e: React.FormEvent) {
+ e.preventDefault();
+ if (!siraDeger || siraDeger < 1 || siraDeger > 4_000_000) {
+ setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
+ return;
+ }
+ setHata(null);
+ adimaGec("bilet");
+ }
+
+ /** Profil yok: sıra+tür kapıya verilir, kapı facetleri yükleyip alan/il adımıyla açılır. */
+ function tercihlereDevam() {
+ kapat();
+ tercihlereGec({ sira: siraDeger, tur });
+ }
+
+ /** Profil var: sıra+tür yerinde yazılır, alan/il seçimleri korunur. */
+ function kaydet() {
+ if (!profil) return;
+ const yeni: TercihProfili = { sira: siraDeger, tur, secimler: profil.secimler };
+ tercihProfiliYaz(yeni);
+ // Depodaki profili tazeler, listedeki riskleri yeni sıraya göre yeniler.
+ profilTamamlandi(yeni);
+ olay("sira_girildi", {
+ kaynak: "duzenle",
+ tur,
+ sira_kovasi: siraKovasi(siraDeger),
+ });
+ // /sonuc tablosu profil çerezinden SSR'lanır; URL değişmediği için tazele.
+ router.refresh();
+ adimaGec("kaydedildi");
+ }
+
+ function tercihleriDeDegistir() {
+ kapat();
+ profilDuzenlemeyiAc();
+ }
+
+ const kendiligindenKapat = useEffectEvent(kapat);
+ useEffect(() => {
+ if (adim !== "kaydedildi") return;
+ const zamanlayici = window.setTimeout(kendiligindenKapat, KAYDEDILDI_BEKLEME);
+ return () => window.clearTimeout(zamanlayici);
+ }, [adim]);
+
+ const duyuru: Record = {
+ kapali: "",
+ tur: "Puan türünü seç.",
+ sira: `${turEtiketi} başarı sıralamanı yaz.`,
+ bilet: `Özet: ${siraDeger.toLocaleString("tr-TR")} · ${turEtiketi}. ${profil ? "Kaydet" : "Tercihlere geç"}.`,
+ kaydedildi: `Sıralaman güncellendi: ${siraDeger.toLocaleString("tr-TR")} · ${turEtiketi}.`,
+ };
+
+ return (
+
+ {adim === "kapali" ? (
+
+ {profil ? (
+
+
+
+
+ {profil.sira.toLocaleString("tr-TR")}
+
+
+ {" "}
+ · {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
+
+
+
+
+ Değiştir
+
+
+ ) : (
+ <>
+
+
+ Sıralamanı gir
+
+
+ Nasıl çalışır?
+
+ >
+ )}
+ {araButonu}
+
+ ) : null}
+
+ {adim === "tur" ? (
+
+
+
+
+
+ Puan türün
+
+
+
adimaGec("sira")}
+ aria-label="İleri, sıralamanı yaz"
+ data-odak
+ className={ILERI}
+ >
+
+
+
+ ) : null}
+
+ {adim === "sira" ? (
+
+ ) : null}
+
+ {adim === "bilet" ? (
+
+
+
+
+
+ Başarı sıralaman
+
+
+ {siraDeger.toLocaleString("tr-TR")}
+
+
+
+
+
+
+
Puan türü
+ {turEtiketi}
+
+
+
Tercihler
+
+ {profil
+ ? `${profil.secimler.kategoriler.length} alan · ${
+ profil.secimler.iller.length
+ ? `${profil.secimler.iller.length} il`
+ : "her il"
+ }`
+ : "Sıradaki adım"}
+
+
+
+ {profil ? (
+
+ Alan ve şehir tercihlerini de değiştir
+
+ ) : null}
+
+
+
+
+
+
+ {profil ? "Kaydet" : "Tercihlere geç"}
+
+
+
+
+ ) : null}
+
+ {adim === "kaydedildi" ? (
+
+
+
+
+
+
+ Sıralaman güncellendi
+
+
+ {siraDeger.toLocaleString("tr-TR")} · {turEtiketi}
+
+
+ {/* Kapatma çarpısı DialogContent varsayılanıyla aynı sınıf seti (§4.3). */}
+
+
+ Kapat
+
+
+ ) : null}
+
+ );
+}
+```
+
+## 5. Bağlantı yaması (nav, kapı deposu, kapı modalı, DESIGN.md §4.33–4.34)
+
+`git apply` ile uygulanır (HEAD `bc920f6` tabanlı; çakışırsa hunk'lar elle taşınır). DESIGN.md bölümleri §6'da.
+
+```diff
+diff --git a/src/features/katalog/components/katalog-arama.tsx b/src/features/katalog/components/katalog-arama.tsx
+index 35aad40..64265bd 100644
+--- a/src/features/katalog/components/katalog-arama.tsx
++++ b/src/features/katalog/components/katalog-arama.tsx
+@@ -25,6 +25,7 @@ import {
+ } from "@/features/liste/hooks/use-tercih-profili";
+ import { preloadTercihProfiliKapisi } from "@/features/liste/components/tercih-profili-kapisi-lazy";
+ import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
++import { SiralamaHapi } from "@/features/sihirbaz/components/siralama-hapi";
+ import { olay } from "@/lib/analitik";
+ import {
+ ArrowRight,
+@@ -266,69 +267,31 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
+
+ return (
+ <>
+- {/* lg altında logo + sağ küme ile çakışır (absolute); o aralıkta gizli */}
+-
+- {profil ? (
+-
+-
+-
+-
+- {profil.sira.toLocaleString("tr-TR")}
+-
+-
+- {" "}
+- · {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
+-
+-
+-
+-
+- Değiştir
+-
+-
+- ) : (
+- <>
++ {/* 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). */}
++
++ 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]"
+ >
+-
+- Sıralamanı gir
++
++ Ara
++
++
++ {macMi ? "⌘" : "Ctrl"}
++
++ K
++
+
+-
+- Nasıl çalışır?
+-
+- >
+- )}
+- 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]"
+- >
+-
+- Ara
+-
+-
+- {macMi ? "⌘" : "Ctrl"}
+-
+- K
+-
+-
++ }
++ />
+
+
+ {/* 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("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>,
+ 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.
+
diff --git a/docs/ekip/BACKLOG.md b/docs/ekip/BACKLOG.md
index 7f0f486..2ca8305 100644
--- a/docs/ekip/BACKLOG.md
+++ b/docs/ekip/BACKLOG.md
@@ -44,6 +44,7 @@ Durum: `aday` → `hazır` (kabul kriteri yazıldı) → `onaylı` (CEO/Bilal)
| 34 | "Yurtdışı imkânı" çipini geri getirecek veri: program/üniversite düzeyinde Erasmus+ ve değişim verisi | Çip 6 Eki 2026'da kalktı; 25 Eyl araştırması açık kaynak bulamadı. Aday: Ulusal Ajans Erasmus+ KA131 hibe/öğrenci sayıları (üniversite düzeyi, yıllık yayın), üniversitelerin değişim anlaşma listeleri | Üniversite düzeyi yeter (program düzeyi yok); kaynak + dönem yazılır; `uyum-puani` boyutu; çip geri gelir | M | aday | KARARLAR 2026-10-06 |
| 35 | Kontenjan türü başına taban sırası (okul birincisi, şehit-gazi yakını, depremzede, 34+ kadın) | Çekmece ve danışman "o kontenjanın taban sırası elimizde yok" diyor; ÖSYM yerleştirme sonuç tablolarında (Tablo-4 PDF) kontenjan türü başına en küçük puan/sıra var, API vermiyor | `scripts/detay.ts --kirilim` ya da ayrı betikle tür başına sıra DB'ye alınır (`obkSira` vb.); `program_detay` ve çekmece gösterir; dilim hesabına **girmez** (adayın hangi kontenjandan yerleşeceği belli değil) | M | aday | KARARLAR 2026-10-06 |
| 36 | `scripts/refresh.ts` yerleşen toplamına `y34kY` dahil değil; `veri-kalite` 6.096/19.857 satırda tür toplamı ≠ `yerlesen2025` | 6 Eki 2026 `veri-kalite` yeni kontrolü ("yerlesen = türlerin yerleşen toplamı") UYARI veriyor; fark yalnız 34+ kadın yerleşenlerden büyük görünüyor, kaynak API'nin `gkY` tanımı doğrulanmalı | `refresh.ts:213` toplamı ve API alan tanımları ham çekimle karşılaştırılır; uyumsuzluk ≤ %2'ye iner ya da kontrol eşiği gerekçesiyle güncellenir; DB yeniden üretilmez, yalnız bir sonraki yıl verisinde uygulanır | S | aday | `scripts/veri-kalite.ts` 6b |
+| 37 | Üst çubuk hapı Arc "Booking pill" gibi adım adım biçim değiştirsin (puan türü → sıralama → bilet → kaydedildi) | 7 Eki 2026'da uygulandı, tarayıcıda doğrulandı, Bilal'in kararıyla geri alındı; ölçülen davranış, tasarım kararları ve tam kod `docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md`'de | Belgedeki iki dosya + yama geri uygulanır; tsc/lint/denetim temiz; profilsiz yol modalı Adım 1/3'te açar, profilli yol sıra+türü yerinde yazar | S (hazır kod) | aday (Bilal kararı) | `docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md` |
Bilal kararı bekleyen, iş olmayanlar: hero başlığı (D1), "üniversitelerden ödeme almayız" beyanı (D8), vakıf üniversitelerinin adıyla doluluk yayını, THE/QS bantları (varsayılan: gösterme), `/hakkinda` + satıcı kimliği (sezon öncesi yeniden), net/OBP "YÖK Atlas kaldırdı, bizde duruyor" cümlesi (önce iki doğrulama + yıl aralığı 2023–2025), dört gece dalının merge'ü (#8a, #9, #14, #15), Rybbit panelinde ekip trafiğini dışlama + Web Vitals.
diff --git a/scripts/tasarim-denetim.ts b/scripts/tasarim-denetim.ts
index cb67f11..4f606ed 100644
--- a/scripts/tasarim-denetim.ts
+++ b/scripts/tasarim-denetim.ts
@@ -103,7 +103,7 @@ const KURALLAR: Kural[] = [
ad: "modal-rozet",
seviye: "hata",
mesaj: "Çekmece/modal içinde SectionEyebrow yok; başlık + tek cümle bağlam (DESIGN.md §4.10)",
- bul: blokIcinde(["DrawerContent", "DialogContent"], /
{/* İlk ziyarette bir kez: çerez bilgilendirmesi (onay penceresi değil) */}
-
+
{/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */}
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index fd564b1..99d0104 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,16 +1,12 @@
import {
ArrowRight,
ArrowUpRight,
- BarChart3,
Coins,
- Compass,
FileText,
ListChecks,
MessageCircleQuestion,
RefreshCcw,
ShieldCheck,
- TrendingUp,
- Wallet,
} from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -22,9 +18,13 @@ import { Parallax } from "@/components/parallax";
import { Reveal } from "@/features/pazarlama/components/reveal";
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
+import { SuzulenHaplar } from "@/features/pazarlama/components/suzulen-haplar";
+import { VeriAkisi } from "@/features/pazarlama/components/veri-akisi";
+import { katalogHaplari } from "@/features/pazarlama/landing-verisi";
import {
- PixelCorner,
PixelDivider,
+ PixelRakam,
+ PixelSahne,
SectionEyebrow,
} from "@/components/pixel-decor";
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
@@ -41,12 +41,6 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
-import {
- Card,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
import {
Table,
TableBody,
@@ -58,11 +52,9 @@ import {
import type { Metadata } from "next";
import {
JsonLd,
- KATALOG_VERI_TARIHI,
faqPageJsonLd,
softwareApplicationJsonLd,
} from "@/lib/seo";
-import { KATALOG_TARIH_METNI } from "@/features/katalog/components/veri-kaynagi";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
@@ -76,20 +68,55 @@ const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
+// Sorun kartlarının piksel sahneleri (DESIGN.md §4.31): hero haritasıyla aynı
+// ızgara dili — `#` doku, `-` silik, `o` turuncu vurgu. Her biri kartın
+// söylediğini çizer: binlerce programın içinde tam 24 seçim; her yıl başka
+// boyda çubuklar ve yalnız sonuncusu işaretli; basamak basamak tırmanan fiyat.
+const SAHNE_IHTIMAL = [
+ "o#o#######o##o##o#######",
+ "####o###o#########o###o#",
+ "######o####o###o########",
+ "#o##o###################",
+ "######o##o####o####o##o#",
+ "#o##########o####o######",
+ ".###..o#......####.#..o.",
+ "..#...###...##......#...",
+];
+const SAHNE_YILLAR = [
+ "........................",
+ ".................##.....",
+ ".........##......##.....",
+ ".##......##......##.....",
+ ".##......##..##..##..oo.",
+ ".##..##..##..##..##..##.",
+ ".##..##..##..##..##..##.",
+ ".##..##..##..##..##..##.",
+];
+const SAHNE_FIYAT = [
+ ".....................ooo",
+ "..................###---",
+ "...............###------",
+ "............###---------",
+ ".........###------------",
+ "......###---------------",
+ "...###------------------",
+ "###---------------------",
+];
+
const problems = [
{
- icon: ListChecks,
+ sahne: SAHNE_IHTIMAL,
title: "24 tercih, binlerce ihtimal",
description: `${PROGRAM_SAYISI_CUMLE} lisans ve önlisans programı arasından sana uyan 24 tanesini doğru sırayla dizmek, tablolarla boğuşarak günler alıyor.`,
},
{
- icon: TrendingUp,
+ sahne: SAHNE_YILLAR,
title: "Geçen yılın puanı yetmiyor",
description:
"Taban sıralamaları her yıl kayıyor; tek yıla bakmak riski gizler.",
},
{
- icon: Wallet,
+ sahne: SAHNE_FIYAT,
title: "Danışman lüks haline geldi",
description:
"Armut'ta tercih danışmanlığı ilanları ortalama 1.000–3.000 TL (Eylül 2026). Ücretsiz robotların çoğu ise yalnızca liste filtreliyor; listeyi kurup gerekçesini yazmıyor.",
@@ -98,26 +125,34 @@ const problems = [
const steps = [
{
- icon: Compass,
- step: "1",
+ step: 1,
title: "Sıralamanı ve hedeflerini anlat",
description:
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.",
},
{
- icon: BarChart3,
- step: "2",
+ step: 2,
title: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
description:
"YÖK Atlas'ın taban sıralaması trendleri, kontenjan değişimleri ve doluluk oranları sıralamana göre elensin; yapay zekâ yalnızca bu havuzdan seçer, dışına çıkamaz.",
},
{
- icon: ShieldCheck,
- step: "3",
+ step: 3,
title: "Dengeli 24'lük listeni al",
description:
"Hayal, dengeli ve güvenli dilimlerine dağıtılmış, her satırı gerekçeli tercih listeni indir; aklına takılanı listen üzerinden yapay zekâya sor.",
},
+] as const;
+
+// "Nasıl çalışır"da akışın yanında duran üç sayı (DESIGN.md §4.28).
+const veriSayilari = [
+ { value: PROGRAM_SAYISI_METNI, label: "Taranan lisans ve önlisans programı" },
+ {
+ value: String(VERI_YIL_SAYISI),
+ birim: "yıl",
+ label: "Taban sıralaması trend verisi",
+ },
+ { value: "%100", label: "Resmî YÖK Atlas kaynağı" },
];
const faqs = [
@@ -217,6 +252,10 @@ const paketOzellikleri = [
] as const;
export default function Home() {
+ // Katalog hapları gerçek veriden (landing-verisi.ts); veri okunamazsa
+ // bölüm çizilmez.
+ const haplar = katalogHaplari();
+
return (
-
-
-
- Veri: YÖK Atlas
- {" "}
- · son güncelleme{" "}
-
- {KATALOG_TARIH_METNI}
-
-
-
- {/* Markayı tek cümlede tanımlayan görünür metin: sitedeki SSS ve
- /kosullar ifadelerinin toplamı, yeni vaat yok. Meta/OG'de zaten
- var; yapay zekâ cevap motorları gövde metnini önceliklendirdiği
- için burada da durur (docs/arastirma/2026-09-24-geo-seo). */}
-
- KolayTercih, YÖK Atlas'ın resmî yerleştirme verisiyle
- sıralamana uygun 24 tercihlik dengeli liste kuran, yapay zekâ
- destekli bir karar destek aracıdır. ÖSYM ya da YÖK ile bağlantılı
- değildir; yerleşme garantisi vermez.
-
@@ -303,23 +321,24 @@ export default function Home() {
+ {/* Sahne kartı (DESIGN.md §4.31): üstte hero'nun piksel diliyle
+ kartın söylediğini çizen sahne, altında başlık ve metin. */}
{problems.map((p, i) => (
-
-
-
-
-
-
+
+
+
+
+
{p.title}
-
-
+
+
{p.description}
-
-
-
+
+
+
))}
@@ -361,77 +380,82 @@ export default function Home() {
-
- {steps.map((s, i) => (
-
-
-
- {s.step}
-
-
-
-
- {s.title}
-
-
- {s.description}
-
-
-
- ))}
+ {/* Adımlar kartsız (DESIGN.md §4.32): numara hero'nun piksel
+ diliyle iri rakam, md'den itibaren rakamlar düz çizgiyle
+ birbirine bağlanır. */}
+
+
+ {steps.map((s, i) => (
+
+
+
+ {i < steps.length - 1 ? (
+
+ ) : null}
+
+ {s.step}. adım:
+ {s.title}
+
+
+ {s.description}
+
+
+
+ ))}
+
- {/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
-
+ {/* Veri akışı + veriye dayalı güven + dürüstlük notu (DESIGN.md
+ §4.28). Üçü de kartsız, sayfa zemininde açıkta durur: geniş
+ ekranda solda dürüstlük notu (sola yaslı), ortada animasyon,
+ sağda (YÖK Atlas girdilerinin yanında) üç sayı (sağa yaslı).
+ lg altında hepsi ortalı; sıra: animasyon → sayılar → not. */}
+
-
- {[
- {
- value: PROGRAM_SAYISI_METNI,
- label: "Taranan lisans ve önlisans programı",
- },
- {
- value: `${VERI_YIL_SAYISI} yıl`,
- label: "Taban sıralaması trend verisi",
- },
- { value: "%100", label: "Resmî YÖK Atlas kaynağı" },
- ].map((stat) => (
+
+
+
+ {veriSayilari.map((stat) => (
// Sayı kahramandır (DESIGN.md §1.1): iri sayı üstte, etiket
// altında; dt anlam için DOM'da önce, görsel sıra order ile.
-
+
{stat.label}
-
+
{stat.value}
+ {stat.birim ? (
+
+ {stat.birim}
+
+ ) : null}
))}
-
-
-
-
-
-
- Dürüstlük notu
-
-
- Yerleşme garantisi vermiyoruz — çünkü kimse veremez.
-
-
- Taban sıralamaları her yıl değişir. Biz sonuç vaadi satmak
- yerine riski satır satır gösterir, daha güvenli bir liste
- kurmana yardımcı oluruz.
-
-
+
+
Dürüstlük notu
+
+ Yerleşme garantisi vermiyoruz —{" "}
+
+ çünkü kimse veremez.
+
+
+
+ Taban sıralamaları her yıl değişir. Biz sonuç vaadi satmak
+ yerine riski satır satır gösterir, daha güvenli bir liste
+ kurmana yardımcı oluruz.
+
@@ -668,6 +692,47 @@ export default function Home() {
+ {/* Katalog: süzülen haplar (DESIGN.md §4.30) */}
+ {haplar.length >= 6 ? (
+ <>
+
+
+
+
+
+ Katalog ücretsiz
+
+ Önce bir bakmak ister misin?
+
+
+ Taban sıralamaları ve kontenjanlar herkese açık. Üye
+ olmadan bölüm bölüm, şehir şehir gez.
+
+
+
+
+
+
+
+ Tüm bölümleri gör
+
+
+
+
+
+
+ >
+ ) : null}
+
{/* Fiyat → SSS geçişi */}
diff --git a/src/components/dev/dev-panel.tsx b/src/components/dev/dev-panel.tsx
index 9c3e948..d6d51af 100644
--- a/src/components/dev/dev-panel.tsx
+++ b/src/components/dev/dev-panel.tsx
@@ -10,7 +10,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Wrench, X } from "lucide-react";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import { authClient } from "@/lib/auth-client";
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
import {
diff --git a/src/components/pixel-decor.tsx b/src/components/pixel-decor.tsx
index 3d1769b..4807897 100644
--- a/src/components/pixel-decor.tsx
+++ b/src/components/pixel-decor.tsx
@@ -470,6 +470,111 @@ export function PixelSira({
);
}
+/**
+ * Piksel sahne (DESIGN.md §4.31): hero'daki dot-matrix Türkiye'nin dilini
+ * küçük resimlere taşır. Harita gibi satır satır bir ızgaradan çizilir:
+ * `#` doku pikseli, `-` silik piksel, `o` turuncu vurgu (hero'daki şehirler
+ * gibi nabız atar), başka her karakter boş. Doku pikselleri imleç ısı
+ * fırçasına katılır. Resim kartın söylediğini gösterir (süs değil); anlamı
+ * yanındaki başlık taşır, bu yüzden aria-hidden.
+ */
+export function PixelSahne({
+ izgara,
+ ton = "doku",
+ yaricap = 26,
+ className,
+}: {
+ izgara: readonly string[];
+ /** "doku": hero haritasının silik mavisi. "dolgun": aynı doku koyu
+ * tonda (0,64–1) — rakam gibi okunması gereken şekiller için. */
+ ton?: "doku" | "dolgun";
+ /** Isı fırçasının yarıçapı (viewBox birimi; hücre 10) */
+ yaricap?: number;
+ className?: string;
+}) {
+ const svgRef = useRef(null);
+ usePixelHeat(svgRef, { radius: yaricap });
+ const cols = Math.max(...izgara.map((satir) => satir.length));
+
+ const pixels = useMemo(() => {
+ const opaklik = (x: number, y: number) =>
+ ton === "dolgun" ? 0.4 + opacityFor(x, y) * 2 : opacityFor(x, y);
+ const sonuc: ReactNode[] = [];
+ let vurgu = 0;
+ izgara.forEach((satir, y) => {
+ for (let x = 0; x < satir.length; x++) {
+ const c = satir[x];
+ if (c === "o") {
+ sonuc.push(
+ pixelRect(x, y, {
+ key: `${x}-${y}`,
+ accent: true,
+ accentDelay: (vurgu++ % 8) * 0.45,
+ }),
+ );
+ } else if (c === "#" || c === "-") {
+ const op =
+ Math.round(opaklik(x, y) * (c === "-" ? 0.5 : 1) * 100) / 100;
+ sonuc.push(
+ ,
+ );
+ }
+ }
+ });
+ return sonuc;
+ }, [izgara, ton]);
+
+ return (
+
+ {pixels}
+
+ );
+}
+
+// 5×7 dot-matrix rakamlar. Yeni bir rakam gerekirse ızgarası buraya eklenir.
+const RAKAMLAR = {
+ 1: ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."],
+ 2: [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"],
+ 3: [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."],
+} as const;
+
+/** Hero'nun piksel diliyle yazılmış iri rakam (ana sayfa adım numaraları). */
+export function PixelRakam({
+ rakam,
+ className,
+}: {
+ rakam: keyof typeof RAKAMLAR;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({
children,
diff --git a/src/components/ui/alt-sayfa.tsx b/src/components/ui/alt-sayfa.tsx
new file mode 100644
index 0000000..b4cc213
--- /dev/null
+++ b/src/components/ui/alt-sayfa.tsx
@@ -0,0 +1,686 @@
+"use client";
+
+// Alt sayfa (bottom sheet) — DESIGN.md §4.10. Ayrıntı her zaman alttan
+// açılan bu sayfadadır. Sayfa parmağı birebir izler: aşağı çekince kapanır,
+// en üst durağın ötesine çekince lastik gibi direnir, bırakınca bırakma
+// hızını taşıyarak en yakın durağa oturur; kapanırken yakalanıp geri
+// açılabilir. Arkadaki perde zamanlayıcıyla değil sayfanın konumuyla koyulaşır.
+//
+// Kaynak: Arc UI "Bottom sheet" (uiarc.dev, 7 Eki 2026); eski vaul çekmecesinin
+// yerini aldı. Bizdeki farklar:
+// - Boy içeriğe göredir (Arc'ta sabit); `duraklar` ekran yüksekliğinin oranıdır
+// ve içerik daha kısaysa içeriğin boyunda kalır. Varsayılan tek durak (0,92).
+// - Parçalı kullanım: başlık, çarpı ve gövde çağıranındır (§4.10 ortalı başlık,
+// §4.3 çarpı). Kaydırılan alan `AltSayfaGovde` olmalıdır — sayfa onun
+// kaydırmasıyla el sıkışır (en üstteyken aşağı çekiş sayfayı indirir).
+// - Yaylar sekmesiz (§6); hareketi azalt'ta kayma yok, yalnız opaklık.
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentProps,
+ type FocusEvent,
+ type KeyboardEvent,
+ type PointerEvent as ReactPointerEvent,
+ type WheelEvent,
+} from "react";
+import { Dialog as DialogPrimitive } from "radix-ui";
+import {
+ AnimatePresence,
+ animate,
+ motion,
+ useMotionValue,
+ usePresence,
+ useReducedMotion,
+ useTransform,
+} from "motion/react";
+import { EGRI, SURE, YAY, birakmaHizi, lastik, lastikTersi } from "@/lib/hareket";
+import { cn } from "@/lib/utils";
+
+type Durak = number | "kapali";
+type Surukleme = {
+ basY: number;
+ koken: number;
+ baslangic: number;
+ oynadi: boolean;
+ ornekler: { t: number; v: number }[];
+};
+
+/** Kapalıyken gölge de görünmesin diye kenarın altında bırakılan pay (px). */
+const KAPALI_PAYI = 40;
+/** Bırakılan sayfanın, hızıyla kaç saniye daha süzüleceği varsayılır. */
+const SUZULME = 0.2;
+/** Bundan hızlı bırakış (px/sn) yönünde en az bir durak ilerler. */
+const FISKE = 320;
+/** En üst durağın ötesine çekişin yaklaşıp geçemediği sınır (px). */
+const ESNEME = 120;
+/** En alçak durakta perdenin tam koyuluğa oranı. */
+const ALT_KOYULUK = 0.78;
+
+/** Ayrılış gelişten kısadır; fiske aynı yayda kendi hızını taşır. */
+const AYRILMA = { ...YAY.akici, visualDuration: 0.3 };
+const SOLMA = { duration: SURE.hizli, ease: EGRI.standart };
+
+const sinirla = (v: number, alt: number, ust: number) => Math.min(ust, Math.max(alt, v));
+const ekranBoyu = () => (typeof window === "undefined" ? 800 : window.innerHeight);
+
+const KokBaglami = createContext<{ acik: boolean; kapat: () => void } | null>(null);
+
+type Tutamak = {
+ onPointerDown: (e: ReactPointerEvent) => void;
+ onPointerMove: (e: ReactPointerEvent) => void;
+ onPointerUp: (e: ReactPointerEvent) => void;
+ onPointerCancel: (e: ReactPointerEvent) => void;
+};
+const SayfaBaglami = createContext<{
+ govdeBagla: (el: HTMLDivElement | null) => void;
+ govdeTekerlek: (e: WheelEvent) => void;
+ govdeOdak: (e: FocusEvent) => void;
+ tutamak: Tutamak;
+} | null>(null);
+
+function AltSayfa({
+ open: disAcik,
+ defaultOpen = false,
+ onOpenChange,
+ children,
+}: {
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (acik: boolean) => void;
+ children: React.ReactNode;
+}) {
+ const [icAcik, setIcAcik] = useState(defaultOpen);
+ const acik = disAcik ?? icAcik;
+ const setAcik = useCallback(
+ (sonraki: boolean) => {
+ if (disAcik === undefined) setIcAcik(sonraki);
+ onOpenChange?.(sonraki);
+ },
+ [disAcik, onOpenChange],
+ );
+ const baglam = useMemo(() => ({ acik, kapat: () => setAcik(false) }), [acik, setAcik]);
+ return (
+
+ {children}
+
+ );
+}
+
+function AltSayfaTetik(props: ComponentProps) {
+ return ;
+}
+
+function AltSayfaKapat(props: ComponentProps) {
+ return ;
+}
+
+type IcerikProps = Omit, "asChild" | "forceMount"> & {
+ /** Dinlenme boyları, ekran yüksekliğinin oranı olarak. En büyüğü sayfanın tavanıdır. */
+ duraklar?: number[];
+ /** Sıralı durakların hangisinde açılacağı; verilmezse en üstteki. */
+ ilkDurak?: number;
+ /** Açılış yayı yerine oturunca `true` ile çağrılır. */
+ onOturdu?: (acik: boolean) => void;
+};
+
+function AltSayfaIcerik(props: IcerikProps) {
+ const kok = useContext(KokBaglami);
+ if (!kok) throw new Error("AltSayfaIcerik, AltSayfa içinde kullanılmalı.");
+ return (
+ // Sayfa ayrılırken monte kalır: kapanış yarı yolda yakalanıp geri alınabilir.
+
+ {kok.acik ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+function Sayfa({
+ className,
+ children,
+ duraklar = [0.92],
+ ilkDurak,
+ onOturdu,
+ kapat,
+ onPointerDownOutside,
+ onInteractOutside,
+ onKeyDown,
+ ...props
+}: IcerikProps & { kapat: () => void }) {
+ const [burada, kaldirilabilir] = usePresence();
+ const azalt = useReducedMotion() ?? false;
+ const durakAnahtari = duraklar.join(",");
+ const kesirler = useMemo(
+ () =>
+ durakAnahtari
+ .split(",")
+ .map(Number)
+ .filter((d) => d > 0)
+ .sort((a, b) => a - b),
+ [durakAnahtari],
+ );
+ const ust = kesirler.length - 1;
+ const cokDurakli = kesirler.length > 1;
+ const [durak, setDurak] = useState(() => sinirla(Math.round(ilkDurak ?? ust), 0, ust));
+ const [duyuru, setDuyuru] = useState("");
+ const sayfaRef = useRef(null);
+ const govdeRef = useRef(null);
+ /** Sayfanın en üst durağının altındaki kayması (px). İlk kare parlamasın diye çok aşağıdan başlar. */
+ const y = useMotionValue(4000);
+ const boy = useMotionValue(0);
+ const varlik = useMotionValue(1);
+ const durakRef = useRef(durak);
+ const hedef = useRef(durak);
+ const surukleme = useRef(null);
+ const varinca = useRef<(() => void) | undefined>(undefined);
+ const tiklamayiYut = useRef(false);
+ const kuruldu = useRef(false);
+ const oturduRef = useRef(onOturdu);
+ /** Sayfanın en son ne zaman girmeye/çıkmaya başladığı: ayrılırken başlayan basış, geri açılan sayfayı kapatmasın. */
+ const varlikDegisimi = useRef({ burada, an: 0 });
+ useLayoutEffect(() => {
+ oturduRef.current = onOturdu;
+ }, [onOturdu]);
+ useLayoutEffect(() => {
+ varlikDegisimi.current = { burada, an: performance.now() };
+ }, [burada]);
+
+ const durakPx = useCallback(
+ (i: number) => Math.min(kesirler[i] * ekranBoyu(), boy.get()),
+ [boy, kesirler],
+ );
+ const ofset = useCallback(
+ (d: Durak) => (d === "kapali" ? boy.get() + KAPALI_PAYI : boy.get() - durakPx(d)),
+ [boy, durakPx],
+ );
+ /** Sayfa tam açık değilken gövde kaydırmaz; çekiş sayfayı taşır (bkz. AltSayfaGovde). */
+ const kisikligiIsaretle = useCallback(() => {
+ sayfaRef.current?.toggleAttribute("data-kisik", ofset(durakRef.current) > 0.5);
+ }, [ofset]);
+ const tamAcik = useCallback(() => ofset(durakRef.current) <= 0.5, [ofset]);
+
+ // Perde sayfanın konumunun fonksiyonudur: sürüklemeyle koyulaşır ve açılır.
+ const perde = useTransform([y, boy, varlik], ([kayma, tam, gorunen]: number[]) => {
+ if (!tam) return 0;
+ const alt = tam - Math.min(kesirler[0] * ekranBoyu(), tam);
+ const kapali = tam + KAPALI_PAYI;
+ const taban = alt > 0 ? ALT_KOYULUK : 1;
+ const koyuluk =
+ kayma <= alt
+ ? 1 - (1 - taban) * (alt ? kayma / alt : 0)
+ : taban * (1 - (kayma - alt) / (kapali - alt));
+ return sinirla(koyuluk, 0, 1) * gorunen;
+ });
+
+ // Her hareket tek motion değerini bulunduğu yerden yeniden hedefler.
+ const git = useCallback(
+ (d: Durak, hiz?: number, sonra?: () => void) => {
+ hedef.current = d;
+ varinca.current = sonra;
+ const nokta = ofset(d);
+ const bitti = () => {
+ const cagri = varinca.current;
+ varinca.current = undefined;
+ cagri?.();
+ };
+ if (azalt) {
+ y.jump(nokta);
+ bitti();
+ return;
+ }
+ animate(y, nokta, {
+ ...(d === "kapali" ? AYRILMA : YAY.akici),
+ ...(hiz === undefined ? {} : { velocity: hiz }),
+ onComplete: bitti,
+ });
+ },
+ [azalt, ofset, y],
+ );
+
+ const dinlen = useCallback(
+ (d: number, hiz?: number) => {
+ if (d !== durakRef.current) {
+ durakRef.current = d;
+ setDurak(d);
+ if (cokDurakli) setDuyuru(d === ust ? "Sayfa genişledi" : "Sayfa küçüldü");
+ }
+ kisikligiIsaretle();
+ // Küçülürken içerik başa döner: kısık hâl hep en üstü gösterir.
+ const govde = govdeRef.current;
+ if (!tamAcik() && govde && govde.scrollTop > 0) {
+ govde.scrollTo({ top: 0, behavior: azalt ? "auto" : "smooth" });
+ }
+ git(d, hiz);
+ },
+ [azalt, cokDurakli, git, kisikligiIsaretle, tamAcik, ust],
+ );
+
+ // Giriş, çıkış ve çıkarken yeniden açılma aynı motion değerini yeniden hedefler.
+ useLayoutEffect(() => {
+ const sayfa = sayfaRef.current;
+ if (!sayfa) return;
+ if (!kuruldu.current) {
+ kuruldu.current = true;
+ boy.set(sayfa.offsetHeight);
+ y.jump(ofset("kapali"));
+ if (azalt) {
+ y.jump(ofset(durakRef.current));
+ varlik.jump(0);
+ }
+ kisikligiIsaretle();
+ }
+ const oturdu = () => oturduRef.current?.(true);
+ if (burada) {
+ if (azalt) {
+ y.jump(ofset(durakRef.current));
+ animate(varlik, 1, { ...SOLMA, onComplete: oturdu });
+ return;
+ }
+ varlik.jump(1);
+ git(durakRef.current, undefined, oturdu);
+ return;
+ }
+ if (azalt) {
+ animate(varlik, 0, { ...SOLMA, onComplete: kaldirilabilir });
+ return;
+ }
+ // Fiske çıkışı kendi hızıyla başlatmıştır; yalnız kaldırma onu bekler.
+ if (hedef.current === "kapali" && y.isAnimating()) varinca.current = kaldirilabilir;
+ else git("kapali", undefined, kaldirilabilir);
+ }, [azalt, boy, burada, git, kaldirilabilir, kisikligiIsaretle, ofset, varlik, y]);
+
+ // İçerik (geç gelen veri) ya da ekran (döndürme, mobil araç çubuğu) değişince
+ // duraklar da kayar: sayfa durağında kalır, uçuş varsa yeniden hedeflenir.
+ useEffect(() => {
+ const sayfa = sayfaRef.current;
+ if (!sayfa || typeof ResizeObserver === "undefined") return;
+ const yenidenOlc = () => {
+ boy.set(sayfa.offsetHeight);
+ const d = hedef.current;
+ kisikligiIsaretle();
+ if (surukleme.current || d === "kapali") return;
+ const nokta = ofset(d);
+ if (Math.abs(nokta - y.get()) < 0.5 && !y.isAnimating()) return;
+ if (y.isAnimating()) git(d, undefined, varinca.current);
+ else y.jump(nokta);
+ };
+ const gozlemci = new ResizeObserver(yenidenOlc);
+ gozlemci.observe(sayfa);
+ window.addEventListener("resize", yenidenOlc);
+ return () => {
+ gozlemci.disconnect();
+ window.removeEventListener("resize", yenidenOlc);
+ };
+ }, [boy, git, kisikligiIsaretle, ofset, y]);
+
+ const suruklemeBasla = useCallback(
+ (clientY: number, an: number) => {
+ // Sayfayı uçarken bile bulunduğu yerden yakala; esnemiş sayfa parmak
+ // mesafesine geri çevrilir ki hiçbir şey sıçramasın.
+ y.stop();
+ const simdiki = y.get();
+ surukleme.current = {
+ basY: clientY,
+ koken: simdiki < 0 ? -lastikTersi(-simdiki, ESNEME) : simdiki,
+ baslangic: durakRef.current,
+ oynadi: false,
+ ornekler: [{ t: an, v: clientY }],
+ };
+ },
+ [y],
+ );
+
+ const suruklemeSur = useCallback(
+ (clientY: number, an: number) => {
+ const s = surukleme.current;
+ if (!s) return;
+ const fark = clientY - s.basY;
+ if (!s.oynadi) {
+ if (Math.abs(fark) < 3) return;
+ s.oynadi = true;
+ sayfaRef.current?.setAttribute("data-surukleniyor", "");
+ }
+ const ham = s.koken + fark;
+ // Aşağı birebir izler (kapatabilsin); en üstün ötesi lastik gibi direnir.
+ y.set(ham < 0 ? -lastik(-ham, ESNEME) : ham);
+ s.ornekler.push({ t: an, v: clientY });
+ if (s.ornekler.length > 12) s.ornekler.shift();
+ },
+ [y],
+ );
+
+ const suruklemeBitir = useCallback(
+ (an: number) => {
+ const s = surukleme.current;
+ surukleme.current = null;
+ sayfaRef.current?.removeAttribute("data-surukleniyor");
+ if (!s) return;
+ if (!s.oynadi) {
+ const d = hedef.current;
+ if (d !== "kapali") git(d, undefined, varinca.current);
+ return;
+ }
+ tiklamayiYut.current = true;
+ const simdiki = y.get();
+ // Esnemiş sayfa parmağın hızını almadan döner; esnemenin ötesine fırlamaz.
+ const hiz = simdiki < 0 ? 0 : birakmaHizi(s.ornekler, an);
+ const varis = simdiki + hiz * SUZULME;
+ const adaylar: Durak[] = [...kesirler.map((_, i) => i), "kapali"];
+ let secilen = adaylar.reduce((iyi, d) =>
+ Math.abs(ofset(d) - varis) < Math.abs(ofset(iyi) - varis) ? d : iyi,
+ );
+ // Hızlı fiske en az bir durak yol alır; en alçaktan aşağı fiske kapatır.
+ if (Math.abs(hiz) > FISKE && secilen === s.baslangic) {
+ const buradaPx = durakPx(s.baslangic);
+ if (hiz < 0) {
+ const yukari = kesirler.findIndex((_, i) => durakPx(i) > buradaPx + 0.5);
+ secilen = yukari === -1 ? s.baslangic : yukari;
+ } else {
+ let asagi = -1;
+ for (let i = kesirler.length - 1; i >= 0; i--) {
+ if (durakPx(i) < buradaPx - 0.5) {
+ asagi = i;
+ break;
+ }
+ }
+ secilen = asagi === -1 ? "kapali" : asagi;
+ }
+ }
+ // Çıkış bu karede parmağın hızıyla başlar; kapanış yalnız inmesini bekler.
+ if (secilen === "kapali") {
+ if (!azalt) git("kapali", hiz);
+ kapat();
+ return;
+ }
+ dinlen(secilen, hiz);
+ },
+ [azalt, dinlen, durakPx, git, kapat, kesirler, ofset, y],
+ );
+
+ // Gövdedeki dokunma: sayfa tam açık değilken çekiş sayfayı taşır; tam
+ // açıkken içerik kaydırır, en üstteyken aşağı çekiş yine sayfayı indirir.
+ useEffect(() => {
+ const govde = govdeRef.current;
+ if (!govde) return;
+ let dokunus: { x: number; y: number; kip: "bekliyor" | "sayfa" | "yerel" } | null = null;
+ const basla = (e: TouchEvent) => {
+ const t = e.touches[0];
+ const yoksay =
+ e.target instanceof Element && e.target.closest("[data-alt-sayfa-yoksay]");
+ dokunus =
+ e.touches.length === 1 && t && !yoksay
+ ? { x: t.clientX, y: t.clientY, kip: "bekliyor" }
+ : null;
+ };
+ const sur = (e: TouchEvent) => {
+ const t = e.touches[0];
+ if (!dokunus || !t || e.touches.length !== 1) return;
+ const dx = t.clientX - dokunus.x;
+ const dy = t.clientY - dokunus.y;
+ if (dokunus.kip === "bekliyor") {
+ if (Math.hypot(dx, dy) < 4) return;
+ const dikey = Math.abs(dy) >= Math.abs(dx);
+ if (tamAcik()) {
+ const kayar = govde.scrollHeight > govde.clientHeight + 1;
+ dokunus.kip =
+ dikey && (!kayar || (govde.scrollTop <= 0 && dy > 0)) ? "sayfa" : "yerel";
+ } else {
+ dokunus.kip = dikey ? "sayfa" : "yerel";
+ }
+ if (dokunus.kip === "sayfa") suruklemeBasla(t.clientY, e.timeStamp);
+ }
+ if (dokunus.kip !== "sayfa") return;
+ if (e.cancelable) e.preventDefault();
+ suruklemeSur(t.clientY, e.timeStamp);
+ };
+ const bitir = (e: TouchEvent) => {
+ if (dokunus?.kip === "sayfa") suruklemeBitir(e.timeStamp);
+ dokunus = null;
+ };
+ govde.addEventListener("touchstart", basla, { passive: true });
+ govde.addEventListener("touchmove", sur, { passive: false });
+ govde.addEventListener("touchend", bitir);
+ govde.addEventListener("touchcancel", bitir);
+ return () => {
+ govde.removeEventListener("touchstart", basla);
+ govde.removeEventListener("touchmove", sur);
+ govde.removeEventListener("touchend", bitir);
+ govde.removeEventListener("touchcancel", bitir);
+ };
+ }, [suruklemeBasla, suruklemeBitir, suruklemeSur, tamAcik]);
+
+ const baglam = useMemo(() => {
+ const tutamak: Tutamak = {
+ onPointerDown(e) {
+ const hedefOge = e.target instanceof Element ? e.target : null;
+ if (e.button !== 0 || !e.isPrimary) return;
+ if (
+ hedefOge?.closest("button, a, input, select, textarea, [role='button']") &&
+ !hedefOge.closest("[data-tutamac]")
+ ) {
+ return;
+ }
+ suruklemeBasla(e.clientY, e.timeStamp);
+ // Hızlı fiske ilk hareket gelmeden alandan çıkabilir: düğme dışında
+ // işaretçi hemen yakalanır.
+ if (!hedefOge?.closest("[data-tutamac]")) {
+ e.currentTarget.setPointerCapture(e.pointerId);
+ }
+ },
+ // Tutamaç düğmesinde işaretçi yalnız gerçekten oynayınca yakalanır; düz tık düğmeye düşer.
+ onPointerMove(e) {
+ if (!surukleme.current) return;
+ if (e.pointerType === "mouse" && e.buttons === 0) {
+ suruklemeBitir(e.timeStamp);
+ return;
+ }
+ suruklemeSur(e.clientY, e.timeStamp);
+ if (surukleme.current.oynadi && !e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.setPointerCapture(e.pointerId);
+ }
+ },
+ onPointerUp(e) {
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ suruklemeBitir(e.timeStamp);
+ },
+ onPointerCancel(e) {
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ suruklemeBitir(e.timeStamp);
+ },
+ };
+ return {
+ govdeBagla: (el: HTMLDivElement | null) => {
+ govdeRef.current = el;
+ },
+ // Henüz ekran dışındaki içeriğe kaydırmak ya da sekmeyle girmek sayfayı sonuna kadar açar.
+ govdeTekerlek: (e: WheelEvent) => {
+ if (!tamAcik() && e.deltaY > 4 && !surukleme.current) dinlen(ust);
+ },
+ govdeOdak: (e: FocusEvent) => {
+ if (tamAcik() || !(e.target instanceof Element)) return;
+ if (e.target.getBoundingClientRect().bottom > window.innerHeight - 8) dinlen(ust);
+ },
+ tutamak,
+ };
+ }, [dinlen, suruklemeBasla, suruklemeBitir, suruklemeSur, tamAcik, ust]);
+
+ function tutamacTik() {
+ if (tiklamayiYut.current) {
+ tiklamayiYut.current = false;
+ return;
+ }
+ dinlen(durakRef.current === ust ? 0 : ust);
+ }
+ function tutamacTus(e: KeyboardEvent) {
+ const simdiki = durakRef.current;
+ const sonraki =
+ e.key === "ArrowUp"
+ ? Math.min(simdiki + 1, ust)
+ : e.key === "ArrowDown"
+ ? Math.max(simdiki - 1, 0)
+ : e.key === "Home"
+ ? ust
+ : e.key === "End"
+ ? 0
+ : null;
+ if (sonraki === null) return;
+ e.preventDefault();
+ dinlen(sonraki);
+ }
+
+ const genis = durak === ust;
+
+ return (
+ <>
+
+
+
+ {
+ // Odak sayfanın kendisine gider: klavye açılmaz, sayfa kaymaz.
+ e.preventDefault();
+ sayfaRef.current?.focus({ preventScroll: true });
+ }}
+ onPointerDownOutside={(e) => {
+ onPointerDownOutside?.(e);
+ const { burada: simdiBurada, an } = varlikDegisimi.current;
+ if (!simdiBurada || e.detail.originalEvent.timeStamp < an) e.preventDefault();
+ }}
+ onInteractOutside={(e) => {
+ onInteractOutside?.(e);
+ // Bildirimdeki "Geri al"a basmak sayfayı kapatmaz.
+ if (e.target instanceof Element && e.target.closest("[data-bildirim-yigini]")) {
+ e.preventDefault();
+ }
+ }}
+ onKeyDown={onKeyDown}
+ {...props}
+ >
+
+
+ {cokDurakli ? (
+
+
+ {genis ? "Sayfayı küçült" : "Sayfayı genişlet"}
+
+ ) : (
+
+ )}
+
+ {children}
+
+ {duyuru}
+
+
+
+ >
+ );
+}
+
+/**
+ * Sayfanın kaydırılan alanı. Sayfa tam açık değilken kaydırmaz (çekiş sayfayı
+ * taşır); içinde kendi sürükleme hareketi olan öğe `data-alt-sayfa-yoksay` alır.
+ */
+function AltSayfaGovde({ className, ...props }: ComponentProps<"div">) {
+ const sayfa = useContext(SayfaBaglami);
+ return (
+
+ );
+}
+
+/** Kaydırmayan başlık alanı: buradan da tutulup çekilir (düğme ve linkler hariç). */
+function AltSayfaSurukle({ className, ...props }: ComponentProps<"div">) {
+ const sayfa = useContext(SayfaBaglami);
+ return (
+
+ );
+}
+
+function AltSayfaBaslik({ className, ...props }: ComponentProps) {
+ return (
+
+ );
+}
+
+function AltSayfaAciklama({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ AltSayfa,
+ AltSayfaTetik,
+ AltSayfaKapat,
+ AltSayfaIcerik,
+ AltSayfaGovde,
+ AltSayfaSurukle,
+ AltSayfaBaslik,
+ AltSayfaAciklama,
+};
diff --git a/src/components/ui/bildirim.tsx b/src/components/ui/bildirim.tsx
new file mode 100644
index 0000000..9df829f
--- /dev/null
+++ b/src/components/ui/bildirim.tsx
@@ -0,0 +1,696 @@
+"use client";
+
+// Bildirim yığını (DESIGN.md §4.16). Kısa sonuç bildirimleri üst kenarda
+// üst üste birikir: yenisi yukarıdan iner, eskiler arkasına saklanıp altından
+// birer şerit gösterir; üzerine gelince (ya da dokununca) yığın listeye
+// açılır. Açıkken ve sekme gizliyken süre durur; sağa kaydırınca kapanır.
+// Aynı `id` ile ikinci çağrı yeni bildirim açmaz, olanı yerinde günceller
+// (ikon, metin ve boy birlikte dönüşür).
+//
+// Kaynak: Arc UI "Toast stack" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
+// - Yığın üstte durur (Arc altta): altta liste çekmecesinin çağrı bandı ve
+// telefonda klavye var; "Geri al" bildirimi onları örtmesin.
+// - Sağlayıcı yok; `toast` modül seviyesinde tek kuyruktur ve çağrı biçimi
+// eski `sonner` ile aynıdır (`toast.success(başlık, { description, action,
+// id, duration })`), kullanım yerleri yalnız import yolunu değiştirir.
+// - Aksiyon düğmesi üst çubuktaki koyu hap; yaylar sekmesiz (§6).
+//
+// İç düzen Arc'ınki gibi tek hizadadır: ikon, başlığın ilk satırı, aksiyon
+// hapı ve çarpı aynı 36 px'lik şeridin ortasına oturur (çarpı ve hap 36 px;
+// ikon kutusu 36 px; metin 8 px üst-alt payla 20 px satır). Tek satırlık
+// bildirimde hepsi kutunun tam ortasındadır, çok satırlıda ilk satırla
+// hizalı kalır. Bizdeki fark: açıklaması olan bildirimde aksiyon metnin
+// altına iner (Türkçe açıklamalar uzun; yanında durursa metni üç-dört satıra
+// sıkıştırır) — Arc bunu dar kutuda yapar.
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type KeyboardEvent,
+ type MouseEvent,
+ type PointerEvent as ReactPointerEvent,
+ type ReactNode,
+} from "react";
+import {
+ AnimatePresence,
+ animate,
+ motion,
+ useIsPresent,
+ useMotionValue,
+ usePresence,
+ useReducedMotion,
+ useTransform,
+ type HTMLMotionProps,
+ type MotionProps,
+} from "motion/react";
+import { CircleCheck, Info, Loader2, OctagonX, TriangleAlert, X } from "lucide-react";
+import { EGRI, SURE, YAY, lastik } from "@/lib/hareket";
+import { cn } from "@/lib/utils";
+
+type Tur = "duz" | "basari" | "bilgi" | "uyari" | "hata" | "yukleniyor";
+
+export interface BildirimSecenekleri {
+ /** Aynı kimlikle çağrı yeni bildirim açmaz, olanı günceller. */
+ id?: string | number;
+ description?: ReactNode;
+ action?: { label: ReactNode; onClick: () => void };
+ /** ms. Verilmezse türe göre; `Infinity` kapatılana kadar kalır. */
+ duration?: number;
+}
+
+type Kayit = {
+ id: string;
+ tur: Tur;
+ baslik: ReactNode;
+ aciklama?: ReactNode;
+ aksiyon?: BildirimSecenekleri["action"];
+ sure: number;
+ sira: number;
+ surum: number;
+};
+
+/** Arkaya saklanan her bildirimin öndekinin altından görünen payı (px). */
+const PAY = 12;
+/** Yığın açıkken bildirimler arası boşluk. */
+const ARALIK = 10;
+/** Saklanan bildirim her adımda bu kadar küçülür. */
+const KUCULME = 0.05;
+/** Aynı anda görünen bildirim; fazlası arkada bekler. */
+const GORUNEN = 3;
+const KUYRUK_SINIRI = 8;
+const TABAN_SURE = 5000;
+/** Bu kadar sağa kaydırılan bildirim büyük ölçüde sönmüştür. */
+const KAYDIRMA_SONME = 280;
+
+const TUR_ETIKETI: Record = {
+ duz: "Bildirim",
+ basari: "Tamam",
+ bilgi: "Bilgi",
+ uyari: "Uyarı",
+ hata: "Hata",
+ yukleniyor: "Sürüyor",
+};
+
+const sureBul = (tur: Tur) =>
+ tur === "yukleniyor"
+ ? Infinity
+ : tur === "uyari" || tur === "hata"
+ ? TABAN_SURE * 1.6
+ : TABAN_SURE;
+
+// ── Kuyruk (modül seviyesinde tek örnek) ────────────────────────────────
+
+let kayitlar: Kayit[] = [];
+let sayac = 0;
+const dinleyiciler = new Set<() => void>();
+const BOS: Kayit[] = [];
+
+function yayinla(sonraki: Kayit[]) {
+ kayitlar = sonraki;
+ dinleyiciler.forEach((d) => d());
+}
+
+function goster(tur: Tur, baslik: ReactNode, secenekler: BildirimSecenekleri = {}): string {
+ const id = secenekler.id != null ? String(secenekler.id) : null;
+ const mevcut = id ? kayitlar.find((k) => k.id === id) : undefined;
+ const ortak = {
+ tur,
+ baslik,
+ aciklama: secenekler.description,
+ aksiyon: secenekler.action,
+ sure: secenekler.duration ?? sureBul(tur),
+ };
+ if (mevcut) {
+ yayinla(
+ kayitlar.map((k) =>
+ k.id === mevcut.id ? { ...k, ...ortak, surum: k.surum + 1 } : k,
+ ),
+ );
+ return mevcut.id;
+ }
+ sayac += 1;
+ const kayit: Kayit = { ...ortak, id: id ?? `bildirim-${sayac}`, sira: sayac, surum: 0 };
+ yayinla([kayit, ...kayitlar].slice(0, KUYRUK_SINIRI));
+ return kayit.id;
+}
+
+function kapat(id?: string | number) {
+ if (id == null) {
+ if (kayitlar.length) yayinla([]);
+ return;
+ }
+ const anahtar = String(id);
+ if (kayitlar.some((k) => k.id === anahtar)) {
+ yayinla(kayitlar.filter((k) => k.id !== anahtar));
+ }
+}
+
+/** Aksiyon kendi bildirimini güncellemediyse (ör. "Geri al") bildirim kapanır. */
+function aksiyonuCalistir(id: string) {
+ const kayit = kayitlar.find((k) => k.id === id);
+ if (!kayit?.aksiyon) return;
+ kayit.aksiyon.onClick();
+ if (kayitlar.find((k) => k.id === id)?.surum === kayit.surum) kapat(id);
+}
+
+type Goster = (baslik: ReactNode, secenekler?: BildirimSecenekleri) => string;
+
+export const toast: Goster & {
+ success: Goster;
+ info: Goster;
+ warning: Goster;
+ error: Goster;
+ loading: Goster;
+ dismiss: typeof kapat;
+} = Object.assign(
+ ((baslik, secenekler) => goster("duz", baslik, secenekler)) as Goster,
+ {
+ success: ((b, s) => goster("basari", b, s)) as Goster,
+ info: ((b, s) => goster("bilgi", b, s)) as Goster,
+ warning: ((b, s) => goster("uyari", b, s)) as Goster,
+ error: ((b, s) => goster("hata", b, s)) as Goster,
+ loading: ((b, s) => goster("yukleniyor", b, s)) as Goster,
+ dismiss: kapat,
+ },
+);
+
+const abone = (dinleyici: () => void) => {
+ dinleyiciler.add(dinleyici);
+ return () => {
+ dinleyiciler.delete(dinleyici);
+ };
+};
+const gorunurlukAbone = (dinleyici: () => void) => {
+ document.addEventListener("visibilitychange", dinleyici);
+ return () => document.removeEventListener("visibilitychange", dinleyici);
+};
+
+// ── Yerleşim ─────────────────────────────────────────────────────────────
+
+type Hedef = { y: number; olcek: number; boy: number; opaklik: number; icerik: number };
+
+/**
+ * Kapalıyken saklanan bildirimler öndekinin boyunu alır ki kenarları hizalı
+ * dursun; açıkken her biri kendi boyunda, aralıkla alt alta dizilir. Liste,
+ * aradaki boşlukları da kaplar: imleç bildirimden bildirime geçerken yığın
+ * kapanmaz.
+ */
+function yigiYerlestir(liste: Kayit[], boylar: Record, acik: boolean) {
+ const on = liste.length ? (boylar[liste[0].id] ?? 0) : 0;
+ const ustler = liste.reduce(
+ (toplam, k, i) => [
+ ...toplam,
+ toplam[i] + (i < GORUNEN ? (boylar[k.id] ?? 0) + ARALIK : 0),
+ ],
+ [0],
+ );
+ const hedefler = liste.map((k, i): Hedef => {
+ const gorunur = i < GORUNEN;
+ const derinlik = Math.min(i, GORUNEN);
+ return acik
+ ? { y: ustler[i], olcek: 1, boy: boylar[k.id] ?? 0, opaklik: gorunur ? 1 : 0, icerik: 1 }
+ : {
+ y: PAY * derinlik,
+ olcek: 1 - KUCULME * derinlik,
+ boy: i === 0 ? (boylar[k.id] ?? 0) : on,
+ opaklik: gorunur ? 1 : 0,
+ icerik: i === 0 ? 1 : 0,
+ };
+ });
+ const listeBoyu = !liste.length
+ ? 0
+ : acik
+ ? ustler[liste.length] - ARALIK
+ : on + PAY * (Math.min(liste.length, GORUNEN) - 1);
+ return { hedefler, listeBoyu };
+}
+
+// ── Tek bildirim ─────────────────────────────────────────────────────────
+
+const solma = { duration: SURE.hizli, ease: EGRI.standart };
+const girisSolma = { duration: SURE.standart, ease: EGRI.giris };
+const yalnizOpaklik: MotionProps = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0, transition: solma },
+ transition: solma,
+};
+const metinDegisimi: MotionProps = {
+ initial: { opacity: 0, y: "0.3em", filter: "blur(4px)" },
+ animate: { opacity: 1, y: "0em", filter: "blur(0px)" },
+ exit: { opacity: 0, y: "-0.3em", filter: "blur(2px)", transition: solma },
+ transition: { duration: SURE.standart, ease: EGRI.giris },
+};
+const ikonDegisimi: MotionProps = {
+ initial: { opacity: 0, scale: 0.6 },
+ animate: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0.6, transition: solma },
+ transition: { ...YAY.varsayilan, opacity: solma },
+};
+
+/** Çıkan kopya sönerken ekran okuyucudan gizlenir; canlı bölge yalnız güncel metni okur. */
+function Degisen(props: HTMLMotionProps<"span">) {
+ const burada = useIsPresent();
+ return ;
+}
+
+function TurIkonu({ tur }: { tur: Tur }) {
+ if (tur === "basari") return ;
+ if (tur === "bilgi") return ;
+ if (tur === "uyari") return ;
+ if (tur === "hata") return ;
+ // Sürüyor: sitenin ortak "bekle" ikonu (§4.20); tür değişince dönen halka durur.
+ return (
+
+ );
+}
+
+// Aksiyon hapı: üst çubuktaki koyu hap, çarpıyla aynı boyda (36 px).
+const AKSIYON_HAPI =
+ "inline-flex h-9 shrink-0 cursor-pointer items-center rounded-full bg-slate-900 px-3.5 text-sm font-semibold whitespace-nowrap text-slate-50 transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]";
+
+type Hareket = {
+ isaretci: number;
+ basX: number;
+ basY: number;
+ koken: number;
+ etkin: boolean;
+ ornekler: { t: number; x: number }[];
+};
+
+function Bildirim({
+ kayit,
+ hedef,
+ acik,
+ onde,
+ gizli,
+ durdu,
+ azalt,
+ olcumBildir,
+ surukleniyor,
+ dokunuldu,
+}: {
+ kayit: Kayit;
+ hedef: Hedef;
+ acik: boolean;
+ onde: boolean;
+ gizli: boolean;
+ durdu: boolean;
+ azalt: boolean;
+ olcumBildir: (id: string, boy: number) => void;
+ surukleniyor: (durum: boolean) => void;
+ dokunuldu: () => void;
+}) {
+ const { id } = kayit;
+ const [burada, kaldirilabilir] = usePresence();
+ const ogeRef = useRef(null);
+ const icerikRef = useRef(null);
+ // Yığın geometrisi motion değerlerinde: yığını, parmağı ya da süreyi
+ // izlemek React'i kare başına yeniden çizdirmez.
+ const y = useMotionValue(0);
+ const olcek = useMotionValue(1);
+ const boy = useMotionValue(0);
+ const opaklik = useMotionValue(0);
+ const icerikOpakligi = useMotionValue(1);
+ const x = useMotionValue(0);
+ const kaydirmaSonmesi = useTransform(x, (v) =>
+ v > 0 ? 1 - Math.min(v / KAYDIRMA_SONME, 1) * 0.75 : 1,
+ );
+ /** Son hedefin uygulandığı açık/kapalı hâl; giriş bitene kadar null. */
+ const uygulanan = useRef(null);
+ const kaydirildi = useRef(false);
+ const kalan = useRef(kayit.sure);
+ const hareket = useRef(null);
+ const tiklamayiYut = useRef(false);
+ const isaretciTuru = useRef("mouse");
+
+ // İlk boyamadan önce ölç: bildirim tam kendi boyu kadar yukarıdan girer.
+ useLayoutEffect(() => {
+ const dugum = icerikRef.current;
+ if (!dugum) return;
+ const olc = () => olcumBildir(id, dugum.offsetHeight + 2);
+ olc();
+ if (typeof ResizeObserver === "undefined") return;
+ const gozlemci = new ResizeObserver(olc);
+ gozlemci.observe(dugum);
+ return () => gozlemci.disconnect();
+ }, [id, olcumBildir]);
+
+ // Yığındaki her değişiklik süren yayı bulunduğu yerden yeniden hedefler;
+ // art arda gelen bildirimler ve hover değişimleri sıraya girmez.
+ useLayoutEffect(() => {
+ if (!burada || hedef.boy <= 0) return;
+ const giriyor = uygulanan.current === null;
+ uygulanan.current = acik;
+ if (azalt) {
+ y.jump(hedef.y);
+ olcek.jump(hedef.olcek);
+ boy.jump(hedef.boy);
+ animate(opaklik, hedef.opaklik, solma);
+ animate(icerikOpakligi, hedef.icerik, solma);
+ return;
+ }
+ if (giriyor) {
+ y.jump(hedef.y - hedef.boy);
+ olcek.jump(hedef.olcek);
+ boy.jump(hedef.boy);
+ icerikOpakligi.jump(hedef.icerik);
+ opaklik.jump(0);
+ }
+ animate(y, hedef.y, YAY.akici);
+ animate(olcek, hedef.olcek, YAY.akici);
+ if (!giriyor) animate(boy, hedef.boy, YAY.akici);
+ animate(opaklik, hedef.opaklik, giriyor ? girisSolma : solma);
+ animate(icerikOpakligi, hedef.icerik, solma);
+ }, [acik, azalt, boy, burada, hedef.boy, hedef.icerik, hedef.olcek, hedef.opaklik, hedef.y, icerikOpakligi, olcek, opaklik, y]);
+
+ // Güncelleme saati baştan başlatır; duraklatma kalan süreyi korur.
+ useEffect(() => {
+ kalan.current = kayit.sure;
+ }, [kayit.surum, kayit.sure]);
+ useEffect(() => {
+ if (!burada || durdu || !Number.isFinite(kalan.current) || kayit.sure <= 0) return;
+ const basladi = performance.now();
+ const zamanlayici = window.setTimeout(() => kapat(id), Math.max(kalan.current, 0));
+ return () => {
+ window.clearTimeout(zamanlayici);
+ kalan.current -= performance.now() - basladi;
+ };
+ }, [burada, durdu, id, kayit.surum, kayit.sure]);
+
+ // Çıkış: geldiği yoldan (yukarı) çekilir; fırlatılan uçmaya devam eder.
+ useEffect(() => {
+ if (burada) return;
+ ogeRef.current?.setAttribute("inert", "");
+ const bitti = () => kaldirilabilir?.();
+ if (azalt) {
+ animate(opaklik, 0, solma).then(bitti);
+ return;
+ }
+ if (!kaydirildi.current) {
+ animate(y, y.get() - 10, YAY.akici);
+ animate(olcek, olcek.get() * 0.96, YAY.akici);
+ }
+ animate(opaklik, 0, {
+ duration: kaydirildi.current ? 0.22 : SURE.cikis,
+ ease: EGRI.standart,
+ }).then(bitti);
+ }, [azalt, burada, kaldirilabilir, olcek, opaklik, y]);
+
+ function basildi(e: ReactPointerEvent) {
+ isaretciTuru.current = e.pointerType;
+ tiklamayiYut.current = false;
+ if (e.button !== 0 || !burada || (e.target as Element).closest("button, a")) return;
+ // Hâlâ yerine dönen bildirimi tam bulunduğu yerden yakala.
+ x.stop();
+ hareket.current = {
+ isaretci: e.pointerId,
+ basX: e.clientX,
+ basY: e.clientY,
+ koken: x.get(),
+ etkin: false,
+ ornekler: [],
+ };
+ }
+
+ function oynadi(e: ReactPointerEvent) {
+ const h = hareket.current;
+ if (!h || h.isaretci !== e.pointerId) return;
+ if (!h.etkin) {
+ const dx = e.clientX - h.basX;
+ const dy = e.clientY - h.basY;
+ if (Math.hypot(dx, dy) < 6) return;
+ if (Math.abs(dy) > Math.abs(dx)) {
+ hareket.current = null;
+ return;
+ }
+ // Buradan ölç: eşiği geçerken bildirim sıçramaz.
+ h.etkin = true;
+ h.basX = e.clientX;
+ tiklamayiYut.current = true;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ surukleniyor(true);
+ }
+ const ham = h.koken + e.clientX - h.basX;
+ // Sağa parmağı birebir izler; sol yanlış yön, lastik gibi direnir.
+ x.set(ham >= 0 ? ham : -lastik(-ham, e.currentTarget.offsetWidth));
+ h.ornekler.push({ t: e.timeStamp, x: ham });
+ while (h.ornekler.length > 2 && e.timeStamp - h.ornekler[0].t > 100) h.ornekler.shift();
+ }
+
+ function birakildi(e: ReactPointerEvent, iptal: boolean) {
+ const h = hareket.current;
+ if (!h || h.isaretci !== e.pointerId) return;
+ hareket.current = null;
+ if (!h.etkin) return;
+ surukleniyor(false);
+ const ilk = h.ornekler[0];
+ const son = h.ornekler[h.ornekler.length - 1];
+ const hiz =
+ iptal || !ilk || !son || son.t === ilk.t || e.timeStamp - son.t > 60
+ ? 0
+ : ((son.x - ilk.x) / (son.t - ilk.t)) * 1000;
+ const genislik = e.currentTarget.offsetWidth;
+ const konum = x.get();
+ // Bırakma hızının götüreceği yer (hızlı kaydırma yavaşlaması).
+ const varis = konum + (hiz / 1000) * 99;
+ if (!iptal && konum > 0 && hiz > -200 && varis > genislik * 0.4) {
+ kaydirildi.current = true;
+ if (!azalt) {
+ animate(x, genislik + 40, {
+ type: "spring",
+ visualDuration: 0.32,
+ bounce: 0,
+ velocity: Math.max(hiz, 0),
+ });
+ }
+ kapat(id);
+ return;
+ }
+ if (azalt) x.jump(0);
+ else animate(x, 0, { ...YAY.birakma, velocity: konum < 0 ? hiz * 0.3 : hiz });
+ }
+
+ function kartaTiklandi(e: MouseEvent) {
+ if (tiklamayiYut.current) {
+ tiklamayiYut.current = false;
+ return;
+ }
+ // Dokunmatikte hover yok: karta dokunmak yığını açar ya da kapatır.
+ if (isaretciTuru.current !== "mouse" && !(e.target as Element).closest("button, a")) {
+ dokunuldu();
+ }
+ }
+
+ function tusaBasildi(e: KeyboardEvent) {
+ if (e.key !== "Escape") return;
+ e.stopPropagation();
+ kapat(id);
+ }
+
+ const degisim = azalt ? yalnizOpaklik : metinDegisimi;
+ // Açıklama varsa aksiyon metnin altında, yoksa başlığın yanında (Arc düzeni).
+ const aksiyonAltta = Boolean(kayit.aciklama);
+
+ return (
+
+ birakildi(e, false)}
+ onPointerCancel={(e) => birakildi(e, true)}
+ onClick={kartaTiklandi}
+ >
+
+ {kayit.tur === "duz" ? null : (
+
+
+
+
+
+
+
+ )}
+
+
{TUR_ETIKETI[kayit.tur]}:
+
+
+
+ {kayit.baslik}
+
+
+
+ {kayit.aciklama ? (
+
{kayit.aciklama}
+ ) : null}
+ {kayit.aksiyon && aksiyonAltta ? (
+
aksiyonuCalistir(id)}
+ className={cn(AKSIYON_HAPI, "mt-2.5")}
+ >
+ {kayit.aksiyon.label}
+
+ ) : null}
+
+ {/* Sağ grup: aksiyon ile çarpı yan yana, ilk satırla aynı hizada */}
+
+ {kayit.aksiyon && !aksiyonAltta ? (
+ aksiyonuCalistir(id)}
+ className={AKSIYON_HAPI}
+ >
+ {kayit.aksiyon.label}
+
+ ) : null}
+ kapat(id)}
+ className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
+ >
+
+ Bildirimi kapat
+
+
+
+
+
+ );
+}
+
+// ── Görünüm alanı ────────────────────────────────────────────────────────
+
+/** Layout'ta bir kez durur; bildirimler `toast(...)` ile her yerden gösterilir. */
+export function BildirimYigini() {
+ const liste = useSyncExternalStore(abone, () => kayitlar, () => BOS);
+ const azalt = !!useReducedMotion();
+ const sayfaGizli = useSyncExternalStore(
+ gorunurlukAbone,
+ () => document.visibilityState === "hidden",
+ () => false,
+ );
+ const [uzerinde, setUzerinde] = useState(false);
+ const [odakta, setOdakta] = useState(false);
+ const [dokunulmus, setDokunulmus] = useState(false);
+ const [surukleme, setSurukleme] = useState(false);
+ const [boylar, setBoylar] = useState>({});
+ const bolgeRef = useRef(null);
+
+ if (dokunulmus && liste.length === 0) setDokunulmus(false);
+ const acik = liste.length > 1 && (uzerinde || odakta || dokunulmus);
+ // Tek bildirimde de üzerine gelmek süreyi durdurur (okumaya vakit kalsın).
+ const durdu = uzerinde || odakta || dokunulmus || surukleme || sayfaGizli;
+
+ const olcumBildir = useCallback(
+ (id: string, boy: number) =>
+ setBoylar((onceki) => (onceki[id] === boy ? onceki : { ...onceki, [id]: boy })),
+ [],
+ );
+ const buda = useCallback(
+ () =>
+ setBoylar((onceki) =>
+ Object.fromEntries(
+ kayitlar.filter((k) => k.id in onceki).map((k) => [k.id, onceki[k.id]]),
+ ),
+ ),
+ [],
+ );
+ const dokunuldu = useCallback(() => setDokunulmus((a) => !a), []);
+
+ useEffect(() => {
+ if (!dokunulmus) return;
+ const disari = (e: PointerEvent) => {
+ if (!bolgeRef.current?.contains(e.target as Node)) setDokunulmus(false);
+ };
+ document.addEventListener("pointerdown", disari);
+ return () => document.removeEventListener("pointerdown", disari);
+ }, [dokunulmus]);
+
+ const { hedefler, listeBoyu } = yigiYerlestir(liste, boylar, acik);
+
+ return (
+ {
+ // Yalnız klavye odağı yığını açar; fareyle düğmeye basmak açık tutmaz.
+ try {
+ if (e.target.matches(":focus-visible")) setOdakta(true);
+ } catch {
+ setOdakta(true);
+ }
+ }}
+ onBlur={(e) => {
+ if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) {
+ setOdakta(false);
+ }
+ }}
+ >
+ {
+ if (e.pointerType === "mouse") setUzerinde(true);
+ }}
+ onPointerLeave={(e) => {
+ if (e.pointerType === "mouse") setUzerinde(false);
+ }}
+ >
+
+ {liste.map((kayit, i) => (
+ = GORUNEN}
+ durdu={durdu}
+ azalt={azalt}
+ olcumBildir={olcumBildir}
+ surukleniyor={setSurukleme}
+ dokunuldu={dokunuldu}
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/ui/bolme-tutamaci.tsx b/src/components/ui/bolme-tutamaci.tsx
new file mode 100644
index 0000000..f809c0b
--- /dev/null
+++ b/src/components/ui/bolme-tutamaci.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+// Bölme tutamacı (DESIGN.md §4.25): yan yana iki alanın arasındaki çizgiyi
+// tutup sürükleyince alanlar yer değiştirir. Tutamaç işaretçiyi birebir
+// izler, sınırın ötesinde lastik gibi direnir ve bırakınca sınıra döner.
+// Odaklanabilir bir ayırıcıdır: ok tuşları boyutlar (Shift büyük adım),
+// Home / End sınırlara gider, çift tık varsayılana döndürür.
+//
+// Kaynak: Arc UI "Resizable panels" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
+// - Arc bölmeleri sabit boylu, kendi içinde kayan bir kap içinde yönetir.
+// /listem'de sayfa kayar ve sohbet sütunu yapışkandır; bu yüzden yalnız
+// tutamaç alındı — genişliği çağıran tutar (ızgaranın CSS değişkeni).
+// - Bölmeyi tamamen kapatma (collapse) yok: liste de sohbet de hep görünür.
+// - Dinlenirken de silik bir tutamaç görünür (fark edilsin); hover tek turuncu (§2.1).
+
+import {
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type PointerEvent as ReactPointerEvent,
+} from "react";
+import { animate, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
+import { YAY } from "@/lib/hareket";
+import { cn } from "@/lib/utils";
+
+/** Sınırın ötesine en çok bu kadar çekilebilir (px). */
+const ESNEME = 44;
+const ADIM = 16;
+const BUYUK_ADIM = 64;
+
+const sinirla = (v: number, alt: number, ust: number) => Math.min(ust, Math.max(alt, v));
+const direnc = (tasma: number) => (tasma * ESNEME * 0.55) / (ESNEME + 0.55 * tasma);
+const bant = (v: number, alt: number, ust: number) =>
+ v < alt ? alt - direnc(alt - v) : v > ust ? ust + direnc(v - ust) : v;
+
+export function BolmeTutamaci({
+ etiket,
+ kontrol,
+ yon = 1,
+ oku,
+ yaz,
+ sinirlar,
+ onSifirla,
+ className,
+}: {
+ /** Ayırıcının boyutlandırdığı alanın adı, ör. "Danışman sohbeti". */
+ etiket: string;
+ /** Boyutlanan alanın `id`'si (`aria-controls`). */
+ kontrol: string;
+ /** `1`: sağa çekmek büyütür; `-1`: sola çekmek büyütür (alan tutamacın sağındaysa). */
+ yon?: 1 | -1;
+ /** Alanın şu anki genişliği (px). */
+ oku: () => number;
+ /** Yeni genişlik (px). Sürükleme boyunca kare başına çağrılır. */
+ yaz: (px: number) => void;
+ /** O anki ekran için en dar / en geniş değer. */
+ sinirlar: () => { enAz: number; enCok: number };
+ /** Çift tık: çağıran varsayılan genişliğe döner. */
+ onSifirla?: () => void;
+ className?: string;
+}) {
+ const azalt = useReducedMotion();
+ const [etkin, setEtkin] = useState(false);
+ const [deger, setDeger] = useState<{ simdi: number; enAz: number; enCok: number } | null>(
+ null,
+ );
+ const surukleme = useRef<{ isaretci: number; basX: number; koken: number; oynadi: boolean } | null>(
+ null,
+ );
+ const donus = useRef(null);
+
+ const bildir = (px: number) => {
+ const { enAz, enCok } = sinirlar();
+ setDeger({ simdi: Math.round(px), enAz: Math.round(enAz), enCok: Math.round(enCok) });
+ };
+
+ /** Genişliği bulunduğu yerden hedefe yayla götürür (klavye, sınıra dönüş). */
+ function git(hedef: number, gecis: typeof YAY.akici | typeof YAY.birakma = YAY.akici) {
+ donus.current?.stop();
+ bildir(hedef);
+ const simdiki = oku();
+ if (azalt || Math.abs(simdiki - hedef) < 0.5) {
+ yaz(hedef);
+ return;
+ }
+ donus.current = animate(simdiki, hedef, { ...gecis, onUpdate: yaz });
+ }
+
+ function basildi(e: ReactPointerEvent) {
+ if (surukleme.current || (e.pointerType === "mouse" && e.button !== 0)) return;
+ donus.current?.stop();
+ // Metin seçimi başlamasın; işaretçi hızlı çıksa da tutamaçta kalır.
+ e.preventDefault();
+ e.currentTarget.setPointerCapture(e.pointerId);
+ e.currentTarget.focus({ preventScroll: true });
+ surukleme.current = { isaretci: e.pointerId, basX: e.clientX, koken: oku(), oynadi: false };
+ }
+
+ function oynadi(e: ReactPointerEvent) {
+ const s = surukleme.current;
+ if (!s || s.isaretci !== e.pointerId) return;
+ const dx = (e.clientX - s.basX) * yon;
+ if (!s.oynadi) {
+ if (Math.abs(dx) < 2) return;
+ s.oynadi = true;
+ setEtkin(true);
+ }
+ const { enAz, enCok } = sinirlar();
+ yaz(bant(s.koken + dx, enAz, enCok));
+ }
+
+ function bitti(e: ReactPointerEvent, iptal: boolean) {
+ const s = surukleme.current;
+ if (!s || s.isaretci !== e.pointerId) return;
+ surukleme.current = null;
+ setEtkin(false);
+ if (!s.oynadi) return;
+ const { enAz, enCok } = sinirlar();
+ // Bırakılan yerde kalır; sınırın ötesindeyse sınıra, iptalse başladığı yere döner.
+ git(iptal ? s.koken : sinirla(oku(), enAz, enCok), YAY.birakma);
+ }
+
+ function tus(e: KeyboardEvent) {
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
+ e.preventDefault();
+ const { enAz, enCok } = sinirlar();
+ const adim = (e.shiftKey ? BUYUK_ADIM : ADIM) * yon;
+ const simdiki = oku();
+ const hedef =
+ e.key === "Home"
+ ? enAz
+ : e.key === "End"
+ ? enCok
+ : sinirla(simdiki + (e.key === "ArrowRight" ? adim : -adim), enAz, enCok);
+ git(hedef);
+ }
+
+ return (
+ bitti(e, false)}
+ onPointerCancel={(e) => bitti(e, true)}
+ onKeyDown={tus}
+ onFocus={() => bildir(oku())}
+ onDoubleClick={() => {
+ donus.current?.stop();
+ onSifirla?.();
+ }}
+ className={cn(
+ // 1 px çizgi + 24 px tutma alanı; çizgi yalnız hover / odak / sürüklemede görünür.
+ "group/bolme z-10 w-6 cursor-col-resize touch-none outline-none select-none",
+ className,
+ )}
+ >
+
+
+
+ );
+}
diff --git a/src/components/ui/cip-grubu.tsx b/src/components/ui/cip-grubu.tsx
new file mode 100644
index 0000000..ff598ea
--- /dev/null
+++ b/src/components/ui/cip-grubu.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+// Seçim çipi ve çip grubu (DESIGN.md §4.24): çok seçimli hap seçimlerinde
+// (sihirbazın alan / şehir / öncelik çipleri) seçilen çip yalnız renk
+// değiştirmez — başında bir tik kendini çizerek büyür, yazı yana kayar,
+// çipin kenarı yayla izler ve komşular yeni yerlerine süzülür. Seçili hâl
+// renk + tik'tir; renk tek başına anlam taşımaz (§2.2).
+//
+// Kaynak: Arc UI "Chip group" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
+// - Arc komşuları layout projeksiyonuyla kaydırır; biz tik yuvasının
+// genişliğini doğrudan yayla açıyoruz, komşular sayfa akışıyla kayar. Çipler
+// kayan alt sayfanın ve yüksekliği ölçülen sihirbaz panelinin içinde
+// yaşıyor: projeksiyon orada erken ölçümle satırları kaydırıyordu.
+// - "+N daha" katlaması alınmadı (şehirlerin tamamı haritayla birlikte görünür).
+// - Sınıf dizisi sitenin çipi (§4.17): 44 px, seçili mavi dolu, hover tek turuncu.
+// Tek seçimde kayan vurgu isteniyorsa `SecimHaplari` (§4.22) kullanılır.
+
+import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
+import { motion, useReducedMotion } from "motion/react";
+import { YAY } from "@/lib/hareket";
+import { cn } from "@/lib/utils";
+
+/** Tik'in yazıdan aldığı yer: 14 px işaret + ardındaki boşluk. */
+const YUVA = 18;
+
+export function SecimCipi({
+ secili,
+ onClick,
+ children,
+ kucuk = false,
+ ton = "notr",
+ className,
+}: {
+ secili: boolean;
+ onClick: () => void;
+ children: ReactNode;
+ /** Çok sayıda çip (iller): 40 px. */
+ kucuk?: boolean;
+ /** `bilgi`: pasif hâli bilgi mavisi tonunda (özel kontenjan durumu); seçili hâl aynı. */
+ ton?: "notr" | "bilgi";
+ className?: string;
+}) {
+ const azalt = useReducedMotion();
+ const gecis = azalt ? { duration: 0 } : YAY.donusum;
+ return (
+
+ {/* Tik yuvası: genişliği yayla açılır, yazı o kadar yana kayar; tik
+ açılan aralıkta kendini çizer, seçim kalkınca aynı yoldan geri gider. */}
+
+
+
+
+
+ {children}
+
+ );
+}
+
+/**
+ * Çipleri saran grup: ekran okuyucuya adını söyler, ok tuşlarıyla çipten çipe
+ * geçilir (Home / End uçlara gider). Yerleşim varsayılanı `flex flex-wrap gap-2`;
+ * ızgara isteyen yer `className` ile değiştirir.
+ */
+export function CipGrubu({
+ etiket,
+ className,
+ children,
+ ...props
+}: Omit, "aria-label" | "role"> & {
+ /** Grubun erişilebilir adı, ör. "İlgi alanları". */
+ etiket: string;
+}) {
+ function tus(e: KeyboardEvent) {
+ const cipler = Array.from(
+ e.currentTarget.querySelectorAll("button[data-cip]"),
+ );
+ const konum = cipler.indexOf(document.activeElement as HTMLButtonElement);
+ if (konum < 0) return;
+ const son = cipler.length - 1;
+ const hedefler: Record = {
+ ArrowRight: konum === son ? 0 : konum + 1,
+ ArrowDown: konum === son ? 0 : konum + 1,
+ ArrowLeft: konum === 0 ? son : konum - 1,
+ ArrowUp: konum === 0 ? son : konum - 1,
+ Home: 0,
+ End: son,
+ };
+ if (!(e.key in hedefler)) return;
+ e.preventDefault();
+ cipler[hedefler[e.key]].focus();
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx
deleted file mode 100644
index ef49bac..0000000
--- a/src/components/ui/drawer.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Drawer as DrawerPrimitive } from "vaul"
-
-import { cn } from "@/lib/utils"
-
-function Drawer({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DrawerTrigger({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DrawerPortal({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DrawerClose({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DrawerOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DrawerHandle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DrawerContent({
- className,
- children,
- showSwipeHandle = true,
- ...props
-}: React.ComponentProps & {
- showSwipeHandle?: boolean
-}) {
- return (
-
-
-
- {showSwipeHandle ? : null}
- {children}
-
-
- )
-}
-
-function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function DrawerTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DrawerDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export {
- Drawer,
- DrawerPortal,
- DrawerOverlay,
- DrawerTrigger,
- DrawerClose,
- DrawerContent,
- DrawerHandle,
- DrawerHeader,
- DrawerFooter,
- DrawerTitle,
- DrawerDescription,
-}
diff --git a/src/components/ui/kart-destesi.tsx b/src/components/ui/kart-destesi.tsx
new file mode 100644
index 0000000..14682ef
--- /dev/null
+++ b/src/components/ui/kart-destesi.tsx
@@ -0,0 +1,615 @@
+"use client";
+
+// Kart destesi (DESIGN.md §4.27): kartlar tek tek gözden geçirilir. Üstteki
+// kart sağa ya da sola sürüklenip fırlatılır; aynı karar düğmelerle ve ok
+// tuşlarıyla da verilir, son karar geri alınır. Arkadaki kartlar üstteki
+// uzaklaştıkça yerinden kalkar. Bırakılan kart parmağın hızını taşır.
+//
+// Kaynak: Arc UI "Card stack" (uiarc.dev, 7 Eki 2026). Bizdeki farklar: sınıf
+// dizisi sitenin kartı ve hap düğmeleri (§4.1, §4.4); onay düğmesi mavi
+// (turuncu yalnız funnel CTA'sı, §2); yaylar sekmesiz, bırakılan kartın yerine
+// dönüşünde tek hafif sekme (§6).
+
+import {
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type KeyboardEvent,
+ type PointerEvent as ReactPointerEvent,
+ type ReactNode,
+} from "react";
+import {
+ AnimatePresence,
+ animate,
+ motion,
+ useMotionValue,
+ useReducedMotion,
+ useSpring,
+ useTransform,
+ type MotionValue,
+} from "motion/react";
+import { Check, RotateCcw, X } from "lucide-react";
+import { EGRI, SURE, YAY, lastik, lastikTersi } from "@/lib/hareket";
+import { cn } from "@/lib/utils";
+
+export type KartKarari = "sol" | "sag";
+
+type Nokta = { x: number; y: number };
+type Cikis = Nokta & { egim: number };
+type Donus = Cikis & { gecikme: number };
+
+/** Destede çizilen kart sayısı; sonuncusu bir adım geride görünmeden bekler. */
+const GORUNEN = 4;
+/** Arkadaki her kartın öndekinin altından görünen payı ve küçülmesi. */
+const PAY = 12;
+const KUCULME = 0.05;
+/** Üst kart sürüklenirken arkadakilerin ne kadar yükseldiği (deste adımı). */
+const KALKIS = 0.4;
+const KALKIS_MESAFESI = 160;
+/** Yatay her piksel için dönüş (derece). */
+const DONME = 0.065;
+/** Bundan hızlı bırakış (px/sn), kart az oynamış olsa da fırlatır. */
+const FISKE = 500;
+/** Dikey yol karar değildir: küçük serbest payın ötesinde lastik gibi direnir. */
+const SERBEST_Y = 48;
+const ESNEME_Y = 140;
+
+const bosAbone = () => () => {};
+const direnY = (ham: number) => {
+ const mesafe = Math.abs(ham);
+ return mesafe <= SERBEST_Y
+ ? ham
+ : Math.sign(ham) * (SERBEST_Y + lastik(mesafe - SERBEST_Y, ESNEME_Y));
+};
+const direnYTersi = (gorunen: number) => {
+ const mesafe = Math.abs(gorunen);
+ return mesafe <= SERBEST_Y
+ ? gorunen
+ : Math.sign(gorunen) * (SERBEST_Y + lastikTersi(mesafe - SERBEST_Y, ESNEME_Y));
+};
+const birim = (v: number) => Math.min(1, Math.max(0, v));
+const FIRLATMA = { type: "spring", visualDuration: 0.5, bounce: 0 } as const;
+const UCARKEN_SOLMA = { duration: 0.22, delay: 0.12, ease: EGRI.standart } as const;
+const SADE_SOLMA = { duration: 0.15, ease: "linear" } as const;
+
+function hizBul(ornekler: { t: number; x: number; y: number }[], simdi: number): Nokta {
+ const yakin = ornekler.filter((o) => simdi - o.t <= 80);
+ const ilk = yakin[0];
+ const son = yakin[yakin.length - 1];
+ if (!ilk || !son || ilk === son || simdi - son.t > 60) return { x: 0, y: 0 };
+ const sn = (son.t - ilk.t) / 1000;
+ return { x: (son.x - ilk.x) / sn, y: (son.y - ilk.y) / sn };
+}
+
+function DesteKarti({
+ id,
+ derinlik,
+ karar,
+ donus,
+ etkin,
+ zIndex,
+ ad,
+ etiketler,
+ kalkis,
+ azalt,
+ firlatmaHizi,
+ onBirak,
+ onFirlatildi,
+ onGitti,
+ children,
+}: {
+ id: string;
+ derinlik: number;
+ karar: KartKarari | null;
+ donus?: Donus;
+ etkin: boolean;
+ zIndex: number;
+ ad: string;
+ etiketler: { sol: string; sag: string };
+ kalkis: MotionValue;
+ azalt: boolean;
+ firlatmaHizi: (id: string) => Nokta | undefined;
+ onBirak: (id: string, konum: Nokta, hiz: Nokta, genislik: number) => boolean;
+ onFirlatildi: (id: string, cikis: Cikis) => void;
+ onGitti: (id: string) => void;
+ children: ReactNode;
+}) {
+ const kartRef = useRef(null);
+ // Geri alınan kart ekranı terk ettiği yerden döner; hareketi azalt'ta yerinde belirir.
+ const yol = donus && !azalt ? donus : undefined;
+ const x = useMotionValue(yol?.x ?? 0);
+ const y = useMotionValue(yol?.y ?? 0);
+ const egim = useMotionValue(yol?.egim ?? 1);
+ const opaklik = useMotionValue(donus ? 0 : 1);
+ const donme = useTransform([x, egim], ([kayma, isaret]: number[]) => kayma * DONME * isaret);
+ // Derinlik yuvasına yayla gider; üst kart çekilirken arkadakiler kalkmaya başlar.
+ const derinlikHedefi = useTransform(kalkis, (v) => derinlik - (derinlik > 0 ? v * KALKIS : 0));
+ const derinlikYayi = useSpring(derinlikHedefi, YAY.akici);
+ const gorunen = azalt ? derinlikHedefi : derinlikYayi;
+ const olcek = useTransform(gorunen, (v) => 1 - v * KUCULME);
+ const inis = useTransform(gorunen, (v) => v * PAY);
+ const varlik = useTransform(gorunen, [GORUNEN - 2, GORUNEN - 1], [1, 0]);
+ const golge = useTransform(gorunen, [0, 2], [0, 1]);
+ const sagDamga = useTransform(x, [16, 96], [0, 1]);
+ const solDamga = useTransform(x, [-96, -16], [1, 0]);
+ const surukleme = useRef<{
+ ox: number;
+ oy: number;
+ ornekler: { t: number; x: number; y: number }[];
+ } | null>(null);
+ const oturdu = useRef(!donus);
+ const kararVerildi = useRef(false);
+ // Eve uçan kart desteyi rahat bırakır; arkadakiler tek harekette yer açar.
+ const eveGidiyor = useRef(false);
+
+ // Üst kart ne kadar yol aldığını bildirir: arkadaki deste sürüklemeye ve fırlatmaya karşılık verir.
+ useEffect(() => {
+ if (!etkin) return;
+ const guncelle = () => {
+ if (!eveGidiyor.current) {
+ kalkis.set(birim(Math.hypot(x.get(), y.get() * 0.5) / KALKIS_MESAFESI));
+ }
+ };
+ const xBirak = x.on("change", guncelle);
+ const yBirak = y.on("change", guncelle);
+ return () => {
+ xBirak();
+ yBirak();
+ };
+ }, [etkin, kalkis, x, y]);
+
+ useLayoutEffect(() => {
+ if (karar) {
+ // Tuş ya da düğme kartı sürüklenirken de karara bağlayabilir; fırlatma parmaktan devralır.
+ surukleme.current = null;
+ eveGidiyor.current = false;
+ kartRef.current?.removeAttribute("data-tutuluyor");
+ kararVerildi.current = true;
+ const yon = karar === "sag" ? 1 : -1;
+ const genislik = kartRef.current?.offsetWidth ?? 320;
+ const cx = x.get();
+ const cy = y.get();
+ // Düğme ve tuşlar varsayılan fiskeyle atar; sürükleme parmağın hızını korur.
+ const hiz = firlatmaHizi(id) ?? { x: yon * 900, y: -120 };
+ const surat = Math.hypot(hiz.x, hiz.y);
+ // Fiske seçilen yöne gidiyorsa onun boyunca, değilse sürükleme boyunca, o da yoksa dümdüz çık.
+ let ux = hiz.x;
+ let uy = hiz.y;
+ if (surat < 200 || Math.sign(ux) !== yon) {
+ ux = Math.abs(cx) > 8 ? cx : yon;
+ uy = Math.abs(cx) > 8 ? cy * 0.5 : -0.12;
+ }
+ const boy = Math.hypot(ux, uy) || 1;
+ ux /= boy;
+ uy /= boy;
+ if (Math.abs(ux) < 0.45) {
+ ux = yon * 0.45;
+ uy = Math.sign(uy || -1) * Math.sqrt(1 - 0.45 * 0.45);
+ }
+ const erim = genislik * 1.6;
+ const cikis = { x: cx + ux * erim, y: cy + uy * erim, egim: egim.get() };
+ onFirlatildi(id, cikis);
+ if (azalt) {
+ animate(opaklik, 0, { ...SADE_SOLMA, onComplete: () => onGitti(id) });
+ return;
+ }
+ animate(x, cikis.x, { ...FIRLATMA, velocity: hiz.x });
+ animate(y, cikis.y, { ...FIRLATMA, velocity: hiz.y });
+ animate(opaklik, 0, { ...UCARKEN_SOLMA, onComplete: () => onGitti(id) });
+ return;
+ }
+ // Geri al ya da çıktığı yerden ilk montaj: oradan dön, süren uçuşu yakala.
+ if (!kararVerildi.current && oturdu.current) return;
+ kararVerildi.current = false;
+ oturdu.current = true;
+ const gecikme = donus?.gecikme ?? 0;
+ if (azalt) {
+ x.jump(0);
+ y.jump(0);
+ animate(opaklik, 1, { ...SADE_SOLMA, delay: gecikme });
+ return;
+ }
+ eveGidiyor.current = true;
+ animate(x, 0, {
+ ...YAY.akici,
+ delay: gecikme,
+ onComplete: () => {
+ eveGidiyor.current = false;
+ },
+ });
+ animate(y, 0, { ...YAY.akici, delay: gecikme });
+ animate(opaklik, 1, { duration: SURE.hizli, delay: gecikme, ease: EGRI.giris });
+ // Bu etkiyi yalnız karar sürer; dönüş konumu o anda bir kez okunur.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [karar]);
+
+ function basildi(e: ReactPointerEvent) {
+ if (!etkin || e.button !== 0 || !e.isPrimary) return;
+ if (
+ e.target instanceof Element &&
+ e.target.closest("button, a, input, select, textarea, [role='button']")
+ ) {
+ return;
+ }
+ x.stop();
+ y.stop();
+ eveGidiyor.current = false;
+ // Üst yarıdan tutulan kart elin gittiği yöne, alt yarıdan tutulan ters yöne eğilir.
+ if (Math.abs(x.get()) < 2) {
+ const kutu = e.currentTarget.getBoundingClientRect();
+ egim.set(e.clientY < kutu.top + kutu.height / 2 ? 1 : -1);
+ }
+ surukleme.current = {
+ ox: e.clientX - x.get(),
+ oy: e.clientY - direnYTersi(y.get()),
+ ornekler: [{ t: e.timeStamp, x: e.clientX, y: e.clientY }],
+ };
+ e.currentTarget.setPointerCapture(e.pointerId);
+ e.currentTarget.setAttribute("data-tutuluyor", "");
+ }
+ function oynadi(e: ReactPointerEvent) {
+ const s = surukleme.current;
+ if (!s || !etkin) return;
+ x.set(e.clientX - s.ox);
+ y.set(direnY(e.clientY - s.oy));
+ s.ornekler.push({ t: e.timeStamp, x: e.clientX, y: e.clientY });
+ if (s.ornekler.length > 12) s.ornekler.shift();
+ }
+ function birakildi(e: ReactPointerEvent) {
+ const s = surukleme.current;
+ surukleme.current = null;
+ e.currentTarget.removeAttribute("data-tutuluyor");
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ if (!s || !etkin) return;
+ const hiz =
+ e.type === "pointercancel" ? { x: 0, y: 0 } : hizBul(s.ornekler, e.timeStamp);
+ if (onBirak(id, { x: x.get(), y: y.get() }, hiz, e.currentTarget.offsetWidth)) return;
+ // Eşiğin altında kart bırakma hızını koruyarak yerine döner.
+ if (azalt) {
+ x.jump(0);
+ y.jump(0);
+ return;
+ }
+ animate(x, 0, { ...YAY.birakma, velocity: hiz.x });
+ animate(y, 0, { ...YAY.birakma, velocity: hiz.y });
+ }
+
+ const ucuyor = karar !== null;
+ return (
+
+
+
+ {children}
+
+
+ {etkin || ucuyor ? (
+ <>
+
+
+ {etiketler.sag}
+
+
+
+ {etiketler.sol}
+
+ >
+ ) : null}
+
+
+ );
+}
+
+const DUGME =
+ "inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full border px-4 text-sm font-semibold whitespace-nowrap outline-none transition-[background-color,border-color,color,opacity,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:active:scale-100";
+const DUGME_TON = {
+ ikincil:
+ "border-slate-200 bg-card text-slate-800 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 aria-disabled:hover:border-slate-200 aria-disabled:hover:bg-card aria-disabled:hover:text-slate-800",
+ sessiz:
+ "border-transparent text-slate-500 hover:text-orange-500 aria-disabled:hover:text-slate-500",
+ onay: "border-primary bg-primary text-white hover:bg-primary/90",
+} as const;
+
+function Dugme({
+ ton,
+ pasif,
+ onClick,
+ children,
+}: {
+ ton: keyof typeof DUGME_TON;
+ pasif: boolean;
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ // aria-disabled: deste bitince odak düğmede kalır, sayfaya düşmez.
+ return (
+ {
+ if (!pasif) onClick();
+ }}
+ >
+ {children}
+
+ );
+}
+
+/** Özel boş durumu çizer; sıfırlama prop olarak gider ki çizim destenin ref'lerine dokunmasın. */
+function BosYuva({
+ ciz,
+ onSifirla,
+}: {
+ ciz: (sifirla: () => void) => ReactNode;
+ onSifirla: () => void;
+}) {
+ return <>{ciz(onSifirla)}>;
+}
+
+export function KartDestesi({
+ ogeler,
+ anahtar,
+ ad,
+ kart,
+ onKarar,
+ onGeriAl,
+ onSifirla,
+ etiketler = { sol: "Geç", sag: "Al" },
+ etiket = "Kartlar",
+ bos,
+ className,
+}: {
+ ogeler: T[];
+ anahtar: (oge: T) => string;
+ /** Kartın kısa adı; erişilebilir etikette ve duyurularda kullanılır. */
+ ad: (oge: T) => string;
+ kart: (oge: T) => ReactNode;
+ onKarar?: (oge: T, karar: KartKarari) => void;
+ onGeriAl?: (oge: T, karar: KartKarari) => void;
+ onSifirla?: () => void;
+ /** İki yönün eylem adı: düğmelerde ve sürüklerken beliren damgalarda. */
+ etiketler?: { sol: string; sag: string };
+ etiket?: string;
+ /** Tüm kartlar bitince gösterilir; verilen fonksiyon desteyi geri getirir. */
+ bos?: (sifirla: () => void) => ReactNode;
+ className?: string;
+}) {
+ const hidrate = useSyncExternalStore(bosAbone, () => true, () => false);
+ const azalt = !!useReducedMotion() && hidrate;
+ const ipucuId = useId();
+ const [konum, setKonum] = useState(0);
+ const [gecmis, setGecmis] = useState<{ anahtar: string; karar: KartKarari }[]>([]);
+ const [ucanlar, setUcanlar] = useState([]);
+ const [donenler, setDonenler] = useState>({});
+ const [duyuru, setDuyuru] = useState("");
+ const kalkis = useMotionValue(0);
+ // Fırlatılan kartların ekranı terk ettiği yer: geri al ve baştan başla onları oradan getirir.
+ const cikislar = useRef>({});
+ const fiskeler = useRef(new Map());
+ const ust = ogeler[konum];
+ const kalan = Math.max(0, ogeler.length - konum);
+
+ function kararVer(karar: KartKarari) {
+ if (!ust) return;
+ const k = anahtar(ust);
+ kalkis.set(0);
+ setKonum(konum + 1);
+ setGecmis([...gecmis, { anahtar: k, karar }]);
+ setUcanlar((simdiki) => [...simdiki, k]);
+ setDonenler({});
+ const geriye = kalan - 1;
+ setDuyuru(
+ `${ad(ust)}: ${etiketler[karar]}. ${geriye ? `${geriye} kart kaldı.` : "Kartlar bitti."}`,
+ );
+ onKarar?.(ust, karar);
+ }
+
+ function geriAl() {
+ const son = gecmis[gecmis.length - 1];
+ const oge = son && ogeler.find((o) => anahtar(o) === son.anahtar);
+ if (!son || !oge) return;
+ const cikis = cikislar.current[son.anahtar];
+ setKonum(konum - 1);
+ setGecmis(gecmis.slice(0, -1));
+ setUcanlar((simdiki) => simdiki.filter((k) => k !== son.anahtar));
+ setDonenler(cikis ? { [son.anahtar]: { ...cikis, gecikme: 0 } } : {});
+ setDuyuru(`${ad(oge)} geri geldi.`);
+ onGeriAl?.(oge, son.karar);
+ }
+
+ function sifirla() {
+ if (!gecmis.length) return;
+ // Deste kendini geri dağıtır: en derinde duracak kart önce, üstteki en son döner.
+ const adet = Math.min(GORUNEN, ogeler.length);
+ const geri: Record = {};
+ ogeler.slice(0, adet).forEach((oge, i) => {
+ const k = anahtar(oge);
+ const cikis = cikislar.current[k];
+ if (cikis && i < konum) geri[k] = { ...cikis, gecikme: (adet - 1 - i) * 0.06 };
+ });
+ setKonum(0);
+ setGecmis([]);
+ setUcanlar([]);
+ setDonenler(geri);
+ setDuyuru("Kartların hepsi destede.");
+ onSifirla?.();
+ }
+
+ function birak(k: string, yer: Nokta, hiz: Nokta, genislik: number) {
+ const yon = Math.sign(yer.x);
+ if (!yon || !ust || anahtar(ust) !== k) return false;
+ const fiske = Math.abs(hiz.x) > FISKE && Math.sign(hiz.x) === yon && Math.abs(yer.x) > 12;
+ const uzak =
+ Math.abs(yer.x) > Math.min(genislik * 0.35, 140) &&
+ !(Math.sign(hiz.x) === -yon && Math.abs(hiz.x) > 300);
+ if (!fiske && !uzak) return false;
+ fiskeler.current.set(k, hiz);
+ kararVer(yon > 0 ? "sag" : "sol");
+ return true;
+ }
+
+ function tus(e: KeyboardEvent) {
+ if (e.target !== e.currentTarget) return;
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ e.preventDefault();
+ kararVer(e.key === "ArrowRight" ? "sag" : "sol");
+ } else if (
+ e.key === "Backspace" ||
+ ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
+ ) {
+ e.preventDefault();
+ geriAl();
+ }
+ }
+
+ const kararlar = new Map(gecmis.map((g) => [g.anahtar, g.karar]));
+ const pencere = new Set(ogeler.slice(konum, konum + GORUNEN).map(anahtar));
+ const cizilenler = ogeler.filter(
+ (o) => pencere.has(anahtar(o)) || ucanlar.includes(anahtar(o)),
+ );
+ const bitti = konum >= ogeler.length && ucanlar.length === 0;
+
+ return (
+
+
+ {cizilenler.map((oge) => {
+ const id = anahtar(oge);
+ const sira = ogeler.indexOf(oge);
+ const karar = kararlar.get(id) ?? null;
+ const derinlik = karar ? 0 : sira - konum;
+ return (
+
{
+ const hiz = fiskeler.current.get(k);
+ fiskeler.current.delete(k);
+ return hiz;
+ }}
+ onBirak={birak}
+ onFirlatildi={(k, cikis) => {
+ cikislar.current[k] = cikis;
+ }}
+ onGitti={(k) => setUcanlar((simdiki) => simdiki.filter((u) => u !== k))}
+ >
+ {kart(oge)}
+
+ );
+ })}
+
+ {bitti ? (
+
+ {bos ? (
+
+ ) : (
+ <>
+ Kartlar bitti
+
+
+ Baştan başla
+
+ >
+ )}
+
+ ) : null}
+
+ {/* Odak halkası sahnenin değil üst kartın çevresinde görünür */}
+
+
+
+ kararVer("sol")}>
+
+ {etiketler.sol}
+
+
+
+ Geri al
+
+ kararVer("sag")}>
+
+ {etiketler.sag}
+
+
+
+ Üstteki kartı sürükle ya da sol ve sağ ok tuşlarıyla karar ver. Geri tuşu son kararı
+ geri alır.
+
+
+ {duyuru}
+
+
+ );
+}
diff --git a/src/components/ui/kaydirma-eylemi.tsx b/src/components/ui/kaydirma-eylemi.tsx
new file mode 100644
index 0000000..0d20fac
--- /dev/null
+++ b/src/components/ui/kaydirma-eylemi.tsx
@@ -0,0 +1,528 @@
+"use client";
+
+// Kaydırma eylemi: liste satırını yana kaydırınca altından eylemler çıkar
+// (posta kutusu gibi). Satır parmağı birebir izler; eylem genişliğinde
+// durur, sonuna kadar çekilince en dıştaki eylem tüm satırı kaplar ve
+// bırakınca uygulanır. Aynı anda tek satır açık kalır; başka yere dokunmak ya
+// da Escape açık satırı kapatır.
+//
+// Kaynak: Arc UI "Swipe actions" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
+// - Arc'taki "Diğer eylemler" menüsü alınmadı: eylem, satırda zaten görünen
+// bir düğmeyle de yapılabilmelidir (klavye ve ekran okuyucu yolu odur;
+// kaydırılan kopya `aria-hidden`).
+// - Satırın listeden çıkışını çağıran yönetir (`AnimatePresence`); bu bileşen
+// yalnız kaydırmayı ve eylemin satırı kaplamasını çizer.
+// - Renkler palet token'ı (§2), yay sekmesiz; bırakışta tek hafif sekme (§6).
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+ type Dispatch,
+ type MouseEvent as ReactMouseEvent,
+ type PointerEvent as ReactPointerEvent,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+import {
+ animate,
+ motion,
+ useMotionValue,
+ useReducedMotion,
+ useTransform,
+ type AnimationPlaybackControls,
+ type MotionValue,
+} from "motion/react";
+import { YAY, lastik } from "@/lib/hareket";
+import { cn } from "@/lib/utils";
+
+export interface KaydirmaEylemi {
+ etiket: string;
+ ikon: ReactNode;
+ /** `tehlike`: geri dönüşü olan silme/çıkarma; etiket sonucu adıyla söyler. */
+ ton?: "notr" | "vurgu" | "tehlike";
+ /** Tam kaydırmada ya da açılan eyleme dokununca çalışır. */
+ onSec: () => void;
+ /** Eylemden sonra satır kayıp gitmez, yerine döner (ör. "Başa al"). */
+ satirKalir?: boolean;
+}
+
+type Yan = "sol" | "sag";
+type Grup = {
+ acikId: string | null;
+ setAcikId: Dispatch>;
+ satirlar: Map;
+};
+type Surukleme = {
+ isaretci: number;
+ tur: string;
+ basX: number;
+ basY: number;
+ koken: number;
+ kilit: "x" | "y" | null;
+ ornekler: [number, number][];
+};
+
+/** Satır açık dururken tek eylemin genişliği (px). */
+const EN = 80;
+/** Bu kadar oynamadan basış kaydırma sayılmaz; dikeyse sayfanın kaydırmasına bırakılır. */
+const BOSLUK = 8;
+/** Bırakma hızının götüreceği yol (sn). */
+const SUZULME = 0.1;
+/** Bundan hızlı bırakış (px/sn) fiske sayılır. */
+const FISKE = 900;
+
+const TON: Record, string> = {
+ notr: "bg-slate-200 text-slate-800",
+ vurgu: "bg-primary text-white",
+ tehlike: "bg-red-500 text-white",
+};
+
+const birim = (v: number) => Math.min(1, Math.max(0, v));
+const yanBul = (v: number): Yan | null => (v > 0 ? "sol" : v < 0 ? "sag" : null);
+
+const GrupBaglami = createContext({
+ acikId: null,
+ setAcikId: () => {},
+ satirlar: new Map(),
+});
+
+/** Satırları sarar: aynı anda yalnız biri açık kalır. DOM üretmez. */
+export function KaydirmaGrubu({ children }: { children: ReactNode }) {
+ const [acikId, setAcikId] = useState(null);
+ const [satirlar] = useState(() => new Map());
+ useEffect(() => {
+ if (!acikId) return;
+ const basildi = (e: PointerEvent) => {
+ const satir = satirlar.get(acikId);
+ if (satir && e.target instanceof Node && satir.contains(e.target)) return;
+ setAcikId(null);
+ };
+ const tus = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setAcikId(null);
+ };
+ document.addEventListener("pointerdown", basildi, true);
+ document.addEventListener("keydown", tus);
+ return () => {
+ document.removeEventListener("pointerdown", basildi, true);
+ document.removeEventListener("keydown", tus);
+ };
+ }, [acikId, satirlar]);
+ const grup = useMemo(() => ({ acikId, setAcikId, satirlar }), [acikId, satirlar]);
+ return {children} ;
+}
+
+function hizBul(ornekler: [number, number][]) {
+ const son = ornekler[ornekler.length - 1];
+ const ilk = ornekler.find((o) => son[0] - o[0] <= 80) ?? son;
+ const gecen = (son[0] - ilk[0]) / 1000;
+ return gecen > 0 ? (son[1] - ilk[1]) / gecen : 0;
+}
+
+/**
+ * Tek satır. `sol` eylemler sol kenarın, `sag` eylemler sağ kenarın altında
+ * durur; her yanda en dıştaki eylem tam kaydırmayla uygulanır.
+ */
+export function KaydirmaSatiri({
+ sol = [],
+ sag = [],
+ tamKaydirma = true,
+ children,
+ className,
+ icerikSinifi,
+}: {
+ sol?: KaydirmaEylemi[];
+ sag?: KaydirmaEylemi[];
+ /** Uzun kaydırma en dıştaki eylemi dokunmadan uygular. */
+ tamKaydirma?: boolean;
+ children: ReactNode;
+ className?: string;
+ /** Kayan içerik kabı: zemin (`bg-card`) ve iç yerleşim buradan. */
+ icerikSinifi?: string;
+}) {
+ const { acikId, setAcikId, satirlar } = useContext(GrupBaglami);
+ const id = useId();
+ const azalt = useReducedMotion() ?? false;
+ const satirRef = useRef(null);
+ const x = useMotionValue(0);
+ const kaplama = useMotionValue(0);
+ const [kaplayan, setKaplayan] = useState<{ yan: Yan; sira: number } | null>(null);
+ const genislik = useRef(0);
+ const surukleme = useRef(null);
+ const yolculuk = useRef(null);
+ const gerilme = useRef(null);
+ const kurulu = useRef(false);
+ const ayriliyor = useRef(false);
+ const tiklamayiYut = useRef(false);
+ const geriZamanlayici = useRef(0);
+
+ const eylemler = (yan: Yan) => (yan === "sol" ? sol : sag);
+ const acikEn = (yan: Yan) => eylemler(yan).length * EN;
+ const enDis = (yan: Yan) => (yan === "sol" ? 0 : sag.length - 1);
+ const uygulanabilir = (yan: Yan) => tamKaydirma && eylemler(yan).length > 0;
+ const esik = (yan: Yan) => Math.max(acikEn(yan) + 48, genislik.current * 0.56);
+
+ function ger(deger: number) {
+ gerilme.current?.stop();
+ if (azalt) {
+ kaplama.jump(deger);
+ return;
+ }
+ gerilme.current = animate(kaplama, deger, YAY.donusum);
+ }
+
+ /** Satırı bir durağa yayla oturtur; bırakma hızını taşır. */
+ function otur(hedef: number, hiz = 0) {
+ if (kurulu.current || (hedef !== 0 && kaplama.get() !== 0)) ger(0);
+ kurulu.current = false;
+ const nereden = x.get();
+ const evde = () => {
+ if (hedef === 0) {
+ gerilme.current?.stop();
+ kaplama.jump(0);
+ }
+ };
+ if (azalt) {
+ x.jump(hedef);
+ evde();
+ } else {
+ // Eve dönerken yay satırı sıfırın ötesine taşımamalı; karşı yanın eylemleri parlar.
+ const eveDogru = hedef === 0 && Math.sign(hiz) === -Math.sign(nereden);
+ const cikis = eveDogru
+ ? Math.sign(hiz) * Math.min(Math.abs(hiz), Math.abs(nereden) * 12)
+ : hiz;
+ yolculuk.current = animate(x, hedef, {
+ ...(hedef === 0 ? YAY.akici : YAY.birakma),
+ velocity: cikis,
+ onComplete: evde,
+ });
+ }
+ if (hedef === 0) setAcikId((simdiki) => (simdiki === id ? null : simdiki));
+ else setAcikId(id);
+ }
+
+ /** Seçilen eylem tüm satırı kaplarken satır onun yönünde çıkar. */
+ function uygula(yan: Yan, sira: number, hiz = 0) {
+ const eylem = eylemler(yan)[sira];
+ if (!eylem || ayriliyor.current) return;
+ const yon = yan === "sol" ? 1 : -1;
+ kurulu.current = false;
+ setKaplayan({ yan, sira });
+ ger(1);
+ if (eylem.satirKalir) {
+ eylem.onSec();
+ otur(0, hiz);
+ return;
+ }
+ ayriliyor.current = true;
+ yolculuk.current?.stop();
+ const son = yon * (genislik.current + 2);
+ if (azalt) x.jump(son);
+ else {
+ yolculuk.current = animate(x, son, {
+ ...YAY.akici,
+ velocity: Math.sign(hiz) === yon ? hiz : 0,
+ });
+ }
+ setAcikId((simdiki) => (simdiki === id ? null : simdiki));
+ eylem.onSec();
+ // Öğeyi listede tutan çağıran, boş bir renk bandı yerine satırını geri alır.
+ window.clearTimeout(geriZamanlayici.current);
+ geriZamanlayici.current = window.setTimeout(() => {
+ if (!ayriliyor.current) return;
+ ayriliyor.current = false;
+ otur(0);
+ }, 1400);
+ }
+
+ function kur(deger: number, isaretciTuru: string) {
+ const yan = yanBul(deger);
+ const sinir = yan ? esik(yan) : Infinity;
+ const sonraki =
+ !!yan && uygulanabilir(yan) && Math.abs(deger) > (kurulu.current ? sinir - 20 : sinir);
+ if (sonraki === kurulu.current || !yan) {
+ if (!yan && kurulu.current) {
+ kurulu.current = false;
+ ger(0);
+ }
+ return;
+ }
+ kurulu.current = sonraki;
+ if (sonraki) {
+ setKaplayan({ yan, sira: enDis(yan) });
+ if (isaretciTuru === "touch") navigator.vibrate?.(8);
+ }
+ ger(sonraki ? 1 : 0);
+ }
+
+ function sinirla(ham: number) {
+ const yan = yanBul(ham);
+ if (!yan) return 0;
+ const olcu = genislik.current || 320;
+ const sinir =
+ eylemler(yan).length === 0 ? 0 : uygulanabilir(yan) ? olcu : acikEn(yan);
+ const mesafe = Math.abs(ham);
+ return Math.sign(ham) * (mesafe <= sinir ? mesafe : sinir + lastik(mesafe - sinir, olcu));
+ }
+
+ function birak(hiz: number) {
+ const deger = x.get();
+ const yan = yanBul(deger);
+ if (!yan || eylemler(yan).length === 0) {
+ otur(0, hiz);
+ return;
+ }
+ const yon = yan === "sol" ? 1 : -1;
+ const mesafe = Math.abs(deger);
+ const disari = hiz * yon;
+ const acik = acikEn(yan);
+ const varis = mesafe + disari * SUZULME;
+ const firlatildi = mesafe > acik && disari > FISKE && varis > esik(yan);
+ // Eve doğru fiske, kurulmuş tam kaydırmayı iptal eder.
+ if (uygulanabilir(yan) && ((kurulu.current && disari > -FISKE) || firlatildi)) {
+ uygula(yan, enDis(yan), hiz);
+ return;
+ }
+ otur(varis > acik / 2 ? yon * acik : 0, hiz);
+ }
+
+ function basildi(e: ReactPointerEvent) {
+ tiklamayiYut.current = false;
+ if (ayriliyor.current || !e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) {
+ return;
+ }
+ // Satırı uçarken yakala: sonraki hareket bulunduğu yerden sürer.
+ yolculuk.current?.stop();
+ surukleme.current = {
+ isaretci: e.pointerId,
+ tur: e.pointerType,
+ basX: e.clientX,
+ basY: e.clientY,
+ koken: x.get(),
+ kilit: null,
+ ornekler: [[e.timeStamp, e.clientX]],
+ };
+ }
+
+ function oynadi(e: ReactPointerEvent) {
+ const s = surukleme.current;
+ if (!s || s.isaretci !== e.pointerId || s.kilit === "y") return;
+ if (!s.kilit) {
+ const dx = e.clientX - s.basX;
+ const dy = e.clientY - s.basY;
+ if (Math.hypot(dx, dy) < BOSLUK) return;
+ if (Math.abs(dy) > Math.abs(dx)) {
+ s.kilit = "y";
+ return;
+ }
+ // Buradan ölç: boşluğu aşarken satır sıçramaz.
+ s.kilit = "x";
+ s.basX = e.clientX;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ setAcikId(id);
+ }
+ const deger = sinirla(s.koken + e.clientX - s.basX);
+ x.set(deger);
+ s.ornekler.push([e.timeStamp, e.clientX]);
+ if (s.ornekler.length > 12) s.ornekler.shift();
+ kur(deger, s.tur);
+ }
+
+ function bitti(e: ReactPointerEvent) {
+ const s = surukleme.current;
+ if (!s || s.isaretci !== e.pointerId) return;
+ surukleme.current = null;
+ if (s.kilit === "x") {
+ tiklamayiYut.current = true;
+ s.ornekler.push([e.timeStamp, e.clientX]);
+ birak(e.type === "pointercancel" ? 0 : hizBul(s.ornekler));
+ return;
+ }
+ // Açık satıra dokunmak, altındakine basmak yerine satırı kapatır.
+ if (s.kilit === null && Math.abs(x.get()) > 0.5) {
+ tiklamayiYut.current = true;
+ otur(0);
+ }
+ }
+
+ function tiklamaYakala(e: ReactMouseEvent) {
+ if (!tiklamayiYut.current) return;
+ tiklamayiYut.current = false;
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ useEffect(() => {
+ const satir = satirRef.current;
+ if (!satir) return;
+ satirlar.set(id, satir);
+ genislik.current = satir.offsetWidth;
+ const gozlemci = new ResizeObserver(([kayit]) => {
+ genislik.current = kayit.contentRect.width;
+ });
+ gozlemci.observe(satir);
+ return () => {
+ gozlemci.disconnect();
+ satirlar.delete(id);
+ window.clearTimeout(geriZamanlayici.current);
+ setAcikId((simdiki) => (simdiki === id ? null : simdiki));
+ };
+ }, [id, satirlar, setAcikId]);
+
+ // Başka satır açıldı ya da dışarı dokunuldu: bu satır kapanır.
+ const kapanRef = useRef(() => {});
+ useEffect(() => {
+ kapanRef.current = () => {
+ if (
+ acikId !== id &&
+ !ayriliyor.current &&
+ surukleme.current?.kilit !== "x" &&
+ x.get() !== 0
+ ) {
+ otur(0);
+ }
+ };
+ });
+ useEffect(() => {
+ kapanRef.current();
+ }, [acikId]);
+
+ return (
+
+ {(["sol", "sag"] as const).map((yan) =>
+ eylemler(yan).map((eylem, sira) => {
+ const adet = eylemler(yan).length;
+ const kat = yan === "sol" ? adet - 1 - sira : sira;
+ const kaplayanKat =
+ kaplayan?.yan === yan
+ ? yan === "sol"
+ ? adet - 1 - kaplayan.sira
+ : kaplayan.sira
+ : null;
+ return (
+ uygula(yan, sira)}
+ />
+ );
+ }),
+ )}
+ e.preventDefault()}
+ >
+ {children}
+
+
+ );
+}
+
+/**
+ * Açılan tek eylem. Kat 0 içeriğe yaslıdır, en yüksek kat satırın dış
+ * kenarındadır. Her katman satırın tamamı kadardır ve yalnız transform'la
+ * kayar: eylemler açılan alanı eşit paylaşır, kaplayan eylem satır boyunca gerilir.
+ */
+function EylemKatmani({
+ eylem,
+ yan,
+ kat,
+ adet,
+ kaplayanKat,
+ x,
+ kaplama,
+ onBas,
+}: {
+ eylem: KaydirmaEylemi;
+ yan: Yan;
+ kat: number;
+ adet: number;
+ kaplayanKat: number | null;
+ x: MotionValue;
+ kaplama: MotionValue;
+ onBas: () => void;
+}) {
+ const yon = yan === "sol" ? 1 : -1;
+ const pay = useTransform(x, (v) => Math.max(0, v * yon) / adet);
+ // Satırın dış kenarından bu katmanın iç kenarına uzaklık.
+ const ic = useTransform([x, kaplama], ([v, ilerleme]: number[]) => {
+ const acilan = Math.max(0, v * yon);
+ const taban = ((adet - kat) * acilan) / adet;
+ if (kaplayanKat === kat) return taban + ilerleme * (acilan - taban);
+ if (kaplayanKat !== null && kat > kaplayanKat) return taban * (1 - ilerleme);
+ return taban;
+ });
+ const kayma = useTransform(ic, (v) => yon * v);
+ // İşaret payının ortasında gider; bu eylem satırı kaplarken içerik kenarına yaslanır.
+ const isaretX = useTransform([pay, kaplama], ([p, ilerleme]: number[]) =>
+ -yon * (kaplayanKat === kat ? p / 2 + ilerleme * (EN / 2 - p / 2) : p / 2),
+ );
+ const ikonGorunum = useTransform([pay, kaplama], ([p, ilerleme]: number[]) => {
+ const kendi = birim((p - EN * 0.25) / (EN * 0.55));
+ if (kaplayanKat === kat) return Math.max(kendi, ilerleme);
+ return kaplayanKat === null ? kendi : kendi * (1 - ilerleme);
+ });
+ const etiketGorunum = useTransform([pay, kaplama], ([p, ilerleme]: number[]) => {
+ const kendi = birim((p - EN * 0.72) / (EN * 0.24));
+ if (kaplayanKat === kat) return Math.max(kendi, ilerleme);
+ return kaplayanKat === null ? kendi : kendi * (1 - ilerleme);
+ });
+ const ikonOlcek = useTransform(ikonGorunum, (v) => 0.6 + 0.4 * v);
+
+ return (
+
+
+
+
+ {eylem.ikon}
+
+
+ {eylem.etiket}
+
+
+
+
+ );
+}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
deleted file mode 100644
index 1d186d5..0000000
--- a/src/components/ui/sonner.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-"use client"
-
-import { useTheme } from "next-themes"
-import { Toaster as Sonner, type ToasterProps } from "sonner"
-import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon } from "lucide-react"
-
-/**
- * Bildirimler (DESIGN.md §4.16). Tema sitenin temasını izler (sistemin
- * değil): sonner kendi "system" algısıyla açık sitede koyu toast metni
- * basıp açıklamayı okunmaz ediyordu (Bilal, 24 Eyl 2026: "toast açıklaması
- * okunmuyor"). Metin ve buton renkleri globals.css'te.
- */
-const Toaster = ({ ...props }: ToasterProps) => {
- const { resolvedTheme } = useTheme()
- return (
-
- ),
- info: (
-
- ),
- warning: (
-
- ),
- error: (
-
- ),
- loading: (
-
- ),
- }}
- style={
- {
- "--normal-bg": "var(--popover)",
- "--normal-text": "var(--popover-foreground)",
- "--normal-border": "var(--border)",
- "--border-radius": "var(--radius)",
- } as React.CSSProperties
- }
- toastOptions={{
- classNames: {
- toast: "cn-toast",
- },
- }}
- {...props}
- />
- )
-}
-
-export { Toaster }
diff --git a/src/components/ui/yazi-donusumu.tsx b/src/components/ui/yazi-donusumu.tsx
new file mode 100644
index 0000000..039588b
--- /dev/null
+++ b/src/components/ui/yazi-donusumu.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+// Yazı dönüşümü (DESIGN.md §4.26): kısa bir ifade yerinde bir sonrakine
+// dönüşür. İki ifadenin ortak harfleri yeni yerlerine süzülür, yeni harfler
+// netleşerek gelir, giden harfler bulanıp söner; genişlik yayla izler ki
+// çevredeki yazı sıçramadan kaysın. Tek satırda kalır; ekran okuyucu düz
+// metni okur.
+//
+// Kaynak: Arc UI "Text morph" (uiarc.dev, 7 Eki 2026). Yay sekmesiz (§6);
+// hareketi azalt'ta yazı geçişsiz değişir.
+
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import {
+ AnimatePresence,
+ animate,
+ motion,
+ useReducedMotion,
+ type AnimationPlaybackControls,
+} from "motion/react";
+import { EGRI, SURE, YAY } from "@/lib/hareket";
+
+/** Giden harfler, yerlerine ilk yeni harf netleşmeden önce silinmeye başlar. */
+const DEVIR = 0.05;
+const HARF_ARALIGI = 0.032;
+
+/** Harfler karakter + kaçıncı geçişi olduğuyla eşleşir: ikinci "i" ikinci "i" ile. */
+function harflereBol(metin: string) {
+ const gorulen = new Map();
+ return Array.from(metin).map((harf) => {
+ const adet = gorulen.get(harf) ?? 0;
+ gorulen.set(harf, adet + 1);
+ return { harf, anahtar: `${harf}-${adet}` };
+ });
+}
+const olc = (el: HTMLElement) => parseFloat(getComputedStyle(el).width);
+
+export function YaziDonusumu({
+ children,
+ className,
+}: {
+ children: string;
+ className?: string;
+}) {
+ const azalt = useReducedMotion();
+ const cerceve = useRef(null);
+ const serit = useRef(null);
+ const genislik = useRef(0);
+ const boyutlama = useRef(null);
+ const harfler = useMemo(() => harflereBol(children), [children]);
+ // Önceki ifade hangi harflerin yeni olduğunu söyler; yalnız onlar sırayla gelir.
+ const [ifadeler, setIfadeler] = useState({ simdiki: children, onceki: children });
+ if (ifadeler.simdiki !== children) {
+ setIfadeler({ simdiki: children, onceki: ifadeler.simdiki });
+ }
+ const kalanlar = useMemo(
+ () => new Set(harflereBol(ifadeler.onceki).map((h) => h.anahtar)),
+ [ifadeler.onceki],
+ );
+ let giren = 0;
+
+ // Çerçeve açık bir genişlik taşır: yeni ifade onu sıçratmaz, yay şeridi izler.
+ useLayoutEffect(() => {
+ const c = cerceve.current;
+ const s = serit.current;
+ if (!c || !s) return;
+ const sonraki = olc(s);
+ if (!Number.isFinite(sonraki)) return;
+ if (genislik.current && Math.abs(sonraki - genislik.current) > 0.5 && !azalt) {
+ boyutlama.current = animate(c, { width: sonraki }, YAY.donusum);
+ } else {
+ boyutlama.current?.stop();
+ c.style.width = `${sonraki}px`;
+ }
+ genislik.current = sonraki;
+ }, [children, azalt]);
+
+ // Font yüklenmesi ya da duyarlı yazı boyu genişliği ifade değişmeden değiştirir: hemen izle.
+ useEffect(() => {
+ const c = cerceve.current;
+ const s = serit.current;
+ if (!c || !s || typeof ResizeObserver === "undefined") return;
+ const gozlemci = new ResizeObserver(() => {
+ const sonraki = olc(s);
+ if (!Number.isFinite(sonraki) || Math.abs(sonraki - genislik.current) < 0.5) return;
+ boyutlama.current?.stop();
+ c.style.width = `${sonraki}px`;
+ genislik.current = sonraki;
+ });
+ gozlemci.observe(s);
+ return () => gozlemci.disconnect();
+ }, []);
+
+ return (
+
+ {children}
+
+
+
+ {harfler.map(({ harf, anahtar }) => {
+ const sira = kalanlar.has(anahtar) ? 0 : giren++;
+ return (
+
+ {harf === " " ? " " : harf}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/features/katalog/components/akademik-ayrinti.tsx b/src/features/katalog/components/akademik-ayrinti.tsx
index f420d0f..1230dbf 100644
--- a/src/features/katalog/components/akademik-ayrinti.tsx
+++ b/src/features/katalog/components/akademik-ayrinti.tsx
@@ -4,13 +4,14 @@ import { useLayoutEffect, useRef, type ReactNode } from "react";
import { animate, useReducedMotion } from "motion/react";
import { ChevronRight, GraduationCap, X } from "lucide-react";
import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer";
+ AltSayfa,
+ AltSayfaKapat,
+ AltSayfaGovde,
+ AltSayfaIcerik,
+ AltSayfaAciklama,
+ AltSayfaBaslik,
+ AltSayfaTetik,
+} from "@/components/ui/alt-sayfa";
/**
* Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı
@@ -38,8 +39,8 @@ export function AkademikAyrinti({
children: ReactNode;
}) {
return (
-
-
+
+
-
-
-
+
+
+
Kapat
-
-
-
+
+
+
{baslik}
-
-
+
+
{baglam}
-
+
{children}
-
-
-
+
+
+
);
}
diff --git a/src/features/katalog/components/bolum-icerik.tsx b/src/features/katalog/components/bolum-icerik.tsx
index 81e54f6..5900931 100644
--- a/src/features/katalog/components/bolum-icerik.tsx
+++ b/src/features/katalog/components/bolum-icerik.tsx
@@ -5,7 +5,6 @@ import {
Armchair,
Briefcase,
Calculator,
- ChartLine,
ChevronRight,
GraduationCap,
Landmark,
@@ -35,11 +34,7 @@ import { VeriKaynagi } from "./veri-kaynagi";
import { NavBaslik } from "./nav-baslik";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
-import {
- KartBaglantisi,
- kisaUniAdi,
- ortaTabanAciklamasi,
-} from "./taban-karti";
+import { KartBaglantisi, kisaUniAdi } from "./taban-karti";
import { trBaslikDuzeni } from "@/lib/slug";
import {
BOLUM_ISTIHDAM_KAYNAK_URL,
@@ -106,7 +101,7 @@ function MezunIstihdami({
const istihdamKaresi =
veri.istihdam != null ? (
-
Kayıtlı çalışan
+ İşe yerleşen
%{ondalik(veri.istihdam)}
{ort.istihdam != null ? (
@@ -165,7 +160,7 @@ function MezunIstihdami({
>
Türkiye geneli, tüm üniversitelerin{" "}
{duzey === "lisans" ? "lisans" : "ön lisans"} mezunları birlikte;
- üniversiteye göre değişir. Kayıtlı çalışan, sigortalı bir işte
+ üniversiteye göre değişir. İşe yerleşen, sigortalı bir işte
çalışanların oranıdır. Kazanç grubu bölümler arası kıyastır, maaş
değildir.
@@ -409,10 +404,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
];
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
- // çekmiyor): öğrencinin sorusuna cevap veren dört sayı — en seçici
- // program, devlette en geniş kapı, orta tabanın yıllar içindeki seyri ve
- // boş kalan koltuk. Hesap lib/taban-ozeti.ts; program/devlet/vakıf sayıları
- // özet paragrafta kalır. Üniversite sayfasıyla aynı düzen (DESIGN.md §4.8).
+ // çekmiyor): öğrencinin sorusuna cevap veren üç sayı — en yüksek taban,
+ // devlette en düşük taban ve boş kalan kontenjan. Orta taban kartı
+ // kaldırıldı (Bilal, 6 Eki 2026). Hesap lib/taban-ozeti.ts; program/
+ // devlet/vakıf sayıları özet paragrafta kalır. Üniversite sayfasıyla aynı
+ // kartlar (DESIGN.md §4.8).
const { taban } = bolum;
const uniBaglantisi = (universite: string) => (
- {/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin
- sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
+ {/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8); burada üç.
+ Telefonda iki sütunda tek kalan üçüncü kart satırı boylu boyunca
+ alır. Derin sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
{ilkSayfa ? (
-
+
- {/* Etiket kısa kalır ("Devlette en geniş" 131 px'te iki satıra
+ {/* Etiket kısa kalır ("Devlette en düşük" 131 px'te iki satıra
kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
- {
- const son = [...taban.ortaTaban].reverse().find((v) => v != null);
- return son != null ? kartSayisi(son) : { deger: "—" };
- })()}
- alt={ortaTabanAciklamasi(taban)}
- />
-
+
+
{varyant === "metin" ? (
{isim} — program ayrıntısı
)}
-
- {/* Çekmece kabı ve çarpı cta-sira-form.tsx'teki çekmeceyle birebir (§4.10, §4.3) */}
-
+ {/* Alt sayfa kabı tek kaynak (alt-sayfa.tsx), çarpı §4.3. İçerik uzun:
+ sayfa ekranın %60'ında açılır (başlık + künye), yukarı çekince ya da
+ kaydırınca tam boya çıkar (§4.19). */}
+ e.stopPropagation()}
>
-
+
Kapat
-
-
-
+
+
+
{isim}
-
-
+
+
{veri
? [
trBaslikDuzeni(veri.program.universite),
@@ -184,13 +188,13 @@ export function ProgramAyrintiButonu({
.filter(Boolean)
.join(" · ")
: (universite ?? "Programın tüm açık verisi")}
-
+
-
-
-
+
+
+
);
}
diff --git a/src/features/katalog/components/taban-karti.tsx b/src/features/katalog/components/taban-karti.tsx
index 6474b20..9c25743 100644
--- a/src/features/katalog/components/taban-karti.tsx
+++ b/src/features/katalog/components/taban-karti.tsx
@@ -1,47 +1,14 @@
import Link from "next/link";
import type { ReactNode } from "react";
-import {
- ortaTabanYonu,
- YON_ETIKET,
- type TabanKumesi,
- type TabanDuzeyi,
- type TabanOzeti,
-} from "@/lib/taban-ozeti";
-import { kisaSayi } from "@/components/ozet-kart";
/**
* Üniversite ve bölüm sayfasının özet kartlarının ortak metin ve link
* yardımcıları (DESIGN.md §4.8). Yıl yıl ayrıntı çekmecesi ("Yıllara göre
- * taban") 6 Eki 2026'da Bilal'in isteğiyle şimdilik kaldırıldı; geri gelirse
- * git geçmişinde `taban-yillari.tsx` + `siranla-kapilar.tsx` olarak duruyor.
+ * taban") ve orta taban kartı 6 Eki 2026'da Bilal'in isteğiyle kaldırıldı;
+ * geri gelirse git geçmişinde `taban-yillari.tsx`, `siranla-kapilar.tsx` ve
+ * `ortaTabanAciklamasi` olarak duruyor.
*/
-/** "2026'da" — yıl eki ünlü uyumuna göre (2023'te, 2026'da, 2027'de). */
-export function yilEki(yil: number): string {
- const son = yil % 10;
- if (son === 3 || son === 4 || son === 5) return `${yil}'te`;
- if (son === 0 || son === 6 || son === 9) return `${yil}'da`;
- return `${yil}'de`;
-}
-
-/** Orta taban kümesinin adı: "Devlet" / "Lisans" / "Önlisans". */
-export function kumeAdi(kume: TabanKumesi, duzey: TabanDuzeyi): string {
- if (kume === "devlet") return "Devlet";
- return duzey === "lisans" ? "Lisans" : "Önlisans";
-}
-
-/** Özet kartının açıklaması: "Devlet · 2021'de 105 bin · gevşedi". */
-export function ortaTabanAciklamasi(taban: TabanOzeti): string {
- const yon = ortaTabanYonu(taban.ortaTaban);
- const kume = kumeAdi(taban.kume, taban.duzey);
- if (!yon) {
- return taban.ortaTabanAdet > 0
- ? `${kume} · ${taban.ortaTabanAdet.toLocaleString("tr-TR")} programın ortası`
- : "henüz taban oluşmadı";
- }
- return `${kume} · ${yilEki(yon.ilkYil)} ${kisaSayi(yon.ilk)} · ${YON_ETIKET[yon.yon]}`;
-}
-
/** Özet kartı açıklamasındaki ad linki (üniversite ↔ bölüm çapraz bağ). */
export function KartBaglantisi({
href,
diff --git a/src/features/katalog/components/universite-icerik.tsx b/src/features/katalog/components/universite-icerik.tsx
index 10a5bc0..7f855cd 100644
--- a/src/features/katalog/components/universite-icerik.tsx
+++ b/src/features/katalog/components/universite-icerik.tsx
@@ -5,7 +5,6 @@ import {
Armchair,
ArrowRight,
Building2,
- ChartLine,
ChevronRight,
Landmark,
LayoutList,
@@ -57,7 +56,7 @@ import { UrapLogo } from "./urap-logo";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { NavBaslik } from "./nav-baslik";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
-import { KartBaglantisi, ortaTabanAciklamasi } from "./taban-karti";
+import { KartBaglantisi } from "./taban-karti";
import { bolumBazAdi } from "@/lib/slug";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
@@ -678,14 +677,15 @@ export function UniversiteIcerik({
{/* Şablon tam 4 hücre bırakıyor: masaüstünde harita 3. sütunu
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Dört
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
- program/kontenjan adedi ilgi çekmiyor): en seçici bölüm
- adıyla, orta tabanın yıllar içindeki seyri, doluluk ve
- Türkiye sırası. Program, birim ve kontenjan sayıları özet
- paragrafta. Hesap lib/taban-ozeti.ts. */}
+ program/kontenjan adedi ilgi çekmiyor): en yüksek ve en
+ düşük taban bölüm adıyla, doluluk ve Türkiye sırası. Orta
+ taban kartı kaldırıldı (Bilal, 6 Eki 2026). Program, birim
+ ve kontenjan sayıları özet paragrafta. Hesap
+ lib/taban-ozeti.ts. */}
{
- const son = [...taban.ortaTaban]
- .reverse()
- .find((v) => v != null);
- return son != null ? kartSayisi(son) : { deger: "—" };
- })()}
- alt={ortaTabanAciklamasi(taban)}
+ ikon={Landmark}
+ etiket="En düşük taban"
+ {...(taban.enGenis
+ ? kartSayisi(taban.enGenis.sira)
+ : { deger: "—" })}
+ alt={
+ taban.enGenis ? (
+ <>
+ {
+ const s = bolumSlugFromIsim(taban.enGenis.isim);
+ return s ? `/bolum/${s}` : null;
+ })()}
+ >
+ {bolumBazAdi(taban.enGenis.isim)}
+ {" "}
+ · {SON_YIL} taban sırası
+ >
+ ) : (
+ "henüz taban oluşmadı"
+ )
+ }
/>
{taban.doluluk ? (
0
- ? `${sayi(taban.doluluk.kontenjan)} kontenjandan ${sayi(taban.doluluk.bosKoltuk)} koltuk boş kaldı · ${SON_YIL}`
+ ? `${sayi(taban.doluluk.kontenjan)} kontenjanın ${sayi(taban.doluluk.bosKoltuk)} tanesi boş kaldı · ${SON_YIL}`
: `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
}
/>
diff --git a/src/features/kullanici/components/giris-form.tsx b/src/features/kullanici/components/giris-form.tsx
index e21880b..ecc646c 100644
--- a/src/features/kullanici/components/giris-form.tsx
+++ b/src/features/kullanici/components/giris-form.tsx
@@ -3,7 +3,7 @@
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, MailCheck } from "lucide-react";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import { authClient } from "@/lib/auth-client";
import { olay } from "@/lib/analitik";
import { Button } from "@/components/ui/button";
diff --git a/src/features/liste/components/liste-cekmecesi.tsx b/src/features/liste/components/liste-cekmecesi.tsx
index 9fdfcde..6db74f5 100644
--- a/src/features/liste/components/liste-cekmecesi.tsx
+++ b/src/features/liste/components/liste-cekmecesi.tsx
@@ -1,18 +1,18 @@
"use client";
-// Manuel 24'lük listenin alt çekmecesi (bottom drawer). Layout'ta bir kez
+// Manuel 24'lük listenin alt sayfası (DESIGN.md §4.10). Layout'ta bir kez
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
//
-// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır
-// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması
-// da kapatır.
+// Alt sayfa (alt-sayfa.tsx): tutamaçtan ya da başlıktan aşağı çekince, liste
+// en üstteyken aşağı çekince kapanır; Escape ve perde tıklaması da kapatır.
//
-// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
-// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları
-// sadeleşir; X butonu her durumda kalır.
+// Satır sıralama yalnız grip tutamağından başlar (dragControls). Satırı sola
+// kaydırmak "Çıkar" eylemini açar, sonuna kadar çekmek uygular
+// (kaydirma-eylemi.tsx, §4.23); aynı iş satırdaki "−" düğmesiyle de yapılır.
+// prefers-reduced-motion: satır animasyonları sadeleşir.
import { useState } from "react";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SayiRulo } from "@/components/ui/sayi-rulo";
@@ -21,13 +21,14 @@ import { UniLogo } from "@/components/uni-logo";
import { RiskEtiketi } from "@/components/risk-etiketi";
import { Button } from "@/components/ui/button";
import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer";
+ AltSayfa,
+ AltSayfaBaslik,
+ AltSayfaGovde,
+ AltSayfaIcerik,
+ AltSayfaKapat,
+ AltSayfaSurukle,
+} from "@/components/ui/alt-sayfa";
+import { KaydirmaGrubu, KaydirmaSatiri } from "@/components/ui/kaydirma-eylemi";
import {
ArrowRight,
Check,
@@ -43,9 +44,7 @@ import {
motion,
Reorder,
useDragControls,
- useMotionValue,
- useTransform,
- type PanInfo,
+ useReducedMotion,
} from "motion/react";
import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri";
@@ -120,7 +119,7 @@ export function ListeCekmecesi() {
const girisli = useOturumDurumu();
const girisUrl = useGirisUrl();
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
- // drawer içinde erken ölçüm satırları kaydırıyordu
+ // sayfa içinde erken ölçüm satırları kaydırıyordu
const [oturdu, setOturdu] = useState(false);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
@@ -144,7 +143,7 @@ export function ListeCekmecesi() {
}
return (
- {
if (v) cekmeceAc();
@@ -153,39 +152,35 @@ export function ListeCekmecesi() {
cekmeceKapat();
}
}}
- handleOnly
- onAnimationEnd={(open) => setOturdu(open)}
>
-
+
{/* Alt sayfa başlığı (DESIGN.md §4.10): ortalı başlık + bağlam;
çarpı §4.3 kanonik sınıf setinde, temizle düğmesi aynı ölçüde
- solda. */}
-
- {liste.length > 0 ? (
-
- geriAlinabilirCikar(
- liste.map((t) => t.id),
- `Listen temizlendi · ${liste.length} program çıkarıldı`,
- )
- }
- title="Listeyi temizle"
- className="absolute top-3 left-3 z-10 flex size-9 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]"
- >
-
- Listeyi temizle
-
- ) : null}
-
-
- Kapat
-
-
+ solda. Başlık alanından da tutulup aşağı çekilir. */}
+ {liste.length > 0 ? (
+
+ geriAlinabilirCikar(
+ liste.map((t) => t.id),
+ `Listen temizlendi · ${liste.length} program çıkarıldı`,
+ )
+ }
+ title="Listeyi temizle"
+ className="absolute top-3 left-3 z-10 flex size-9 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]"
+ >
+
+ Listeyi temizle
+
+ ) : null}
+
+
+ Kapat
+
+
+
Kendi Listem
-
+
{/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3). Bilal'in
kararı (24 Eyl 2026): buradaki piksel liste başlığı çok dolu
gösteriyordu; ilerleme çubuğuna geri dönüldü. */}
@@ -238,11 +233,9 @@ export function ListeCekmecesi() {
Güvenli yok; alta birkaç tane eklemek riski azaltır.
) : null}
-
+
-
+
{liste.length === 0 ? (
) : (
-
-
- {liste.map((t, i) => (
-
- ))}
-
-
+
+
+
+ {liste.map((t, i) => (
+
+ ))}
+
+
+
)}
{liste.length < MANUEL_LISTE_MAX ? (
@@ -290,9 +285,9 @@ export function ListeCekmecesi() {
Program ekle
) : null}
-
+
-
+
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
{girisli === false ? (
@@ -373,15 +368,12 @@ export function ListeCekmecesi() {
)}
-
-
-
+
+
+
);
}
-/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
-const SIL_ESIK = 90;
-
function CekmeceSatiri({
tercih,
sira,
@@ -392,19 +384,9 @@ function CekmeceSatiri({
projeksiyonAcik: boolean;
}) {
const siraKontrol = useDragControls();
-
- // Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
- const x = useMotionValue(0);
- const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
-
- const kaydirinca = (_: unknown, info: PanInfo) => {
- if (
- info.offset.x > SIL_ESIK ||
- (info.offset.x > 32 && info.velocity.x > 500)
- ) {
- geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
- }
- };
+ const azalt = useReducedMotion();
+ const cikarIste = () =>
+ geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
return (
- {/* Sil bandı: kart sağa kaydıkça altından görünür */}
-
-
-
-
- {/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
- ,
+ ton: "tehlike",
+ onSec: cikarIste,
+ },
+ ]}
+ icerikSinifi="flex items-center gap-3 bg-card py-3"
>
{
- e.stopPropagation(); // yatay kaydırmayı tetiklemesin
+ e.stopPropagation();
siraKontrol.start(e);
}}
className="-ml-1 flex h-11 w-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-slate-300 transition-colors hover:text-slate-500 active:cursor-grabbing"
@@ -458,7 +438,6 @@ function CekmeceSatiri({
e.stopPropagation()} // kaydırmayı tetiklemesin
className={SATIR_AD}
>
{tercih.isim}
@@ -468,7 +447,6 @@ function CekmeceSatiri({
e.stopPropagation()} // kaydırmayı tetiklemesin
title={`${tercih.universite} taban puanları sayfası`}
className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
>
@@ -491,18 +469,16 @@ function CekmeceSatiri({
- geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
- }
- onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
+ onClick={cikarIste}
// Satırdan çıkarma "−" ile, çerçevesiz: kapatma çarpısıyla (§4.3)
- // karışmasın diye çarpı yalnız çekmecenin kendisini kapatır.
+ // karışmasın diye çarpı yalnız sayfanın kendisini kapatır. Kaydırma
+ // eyleminin klavye ve ekran okuyucu yolu da bu düğmedir.
className="inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-[background-color,color,transform] duration-150 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92]"
>
{tercih.isim} listeden çıkar
-
+
);
}
diff --git a/src/features/liste/components/program-ekle-butonu.tsx b/src/features/liste/components/program-ekle-butonu.tsx
index 30c21cc..10ba5ce 100644
--- a/src/features/liste/components/program-ekle-butonu.tsx
+++ b/src/features/liste/components/program-ekle-butonu.tsx
@@ -2,7 +2,7 @@
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { ArrowRight, Check, Plus } from "lucide-react";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {
diff --git a/src/features/liste/components/tercih-profili-kapisi.tsx b/src/features/liste/components/tercih-profili-kapisi.tsx
index f8584c1..53399b4 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 { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
diff --git a/src/features/odeme/components/satin-al-form.tsx b/src/features/odeme/components/satin-al-form.tsx
index 94dc212..63dce5a 100644
--- a/src/features/odeme/components/satin-al-form.tsx
+++ b/src/features/odeme/components/satin-al-form.tsx
@@ -3,7 +3,7 @@
import { useLayoutEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import { Button } from "@/components/ui/button";
import { olay } from "@/lib/analitik";
import { baslatOdeme } from "../odeme-actions";
diff --git a/src/features/pazarlama/components/deste-demosu.tsx b/src/features/pazarlama/components/deste-demosu.tsx
new file mode 100644
index 0000000..1f4f260
--- /dev/null
+++ b/src/features/pazarlama/components/deste-demosu.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+// Ana sayfadaki "Dene" bölümü (DESIGN.md §4.27): örnek bir sıralamaya göre
+// gerçek programlar kart destesinde tek tek sorulur — "listene girer mi?".
+// Ürünün yaptığı işin (24 tercihi riskine göre seçmek) elle bir tadımlığı.
+// Veri sunucudan gelir (landing-verisi.ts); burada uydurma sayı yoktur.
+
+import { useState } from "react";
+import Link from "next/link";
+import { ArrowRight, Landmark, MapPin } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { KartDestesi } from "@/components/ui/kart-destesi";
+import { RiskEtiketi } from "@/components/risk-etiketi";
+import type { OrnekProgram } from "@/features/pazarlama/landing-verisi";
+
+function ProgramKarti({ program, ornekSira }: { program: OrnekProgram; ornekSira: string }) {
+ return (
+
+ {/* Künye şeridi: sürüklerken karar damgaları bu şeridin üstünde belirir */}
+
+ {program.il ? (
+
+
+ {program.il}
+
+ ) : null}
+
+
+ Devlet
+
+
+
+
+ {program.isim}
+
+
{program.universite}
+
+
Son taban sıralaması
+
+ {program.tabanSira.toLocaleString("tr-TR")}
+
+
+
+ ·
+ senin sıran {ornekSira}
+
+
+
+
+ );
+}
+
+export function DesteDemosu({
+ programlar,
+ ornekSira,
+}: {
+ programlar: OrnekProgram[];
+ /** Biçimlenmiş örnek sıralama ("85.000"). */
+ ornekSira: string;
+}) {
+ const [alinanlar, setAlinanlar] = useState([]);
+
+ return (
+ p.id}
+ ad={(p) => `${p.isim}, ${p.universite}`}
+ etiket="Örnek programlar"
+ etiketler={{ sol: "Geç", sag: "Listeme al" }}
+ onKarar={(p, karar) => {
+ if (karar === "sag") setAlinanlar((a) => [...a, p.id]);
+ }}
+ onGeriAl={(p, karar) => {
+ if (karar === "sag") setAlinanlar((a) => a.filter((id) => id !== p.id));
+ }}
+ onSifirla={() => setAlinanlar([])}
+ kart={(p) => }
+ bos={(sifirla) => (
+ <>
+
+ {alinanlar.length > 0
+ ? `${alinanlar.length} programı listene aldın`
+ : "Hiçbirini almadın, olur"}
+
+
+ Gerçek listede bunu 24 tercih için biz yaparız; her birinin gerekçesini de yazarız.
+
+
+
+ Sıralamamı gir
+
+
+
+
+ Kartları baştan gör
+
+ >
+ )}
+ />
+ );
+}
diff --git a/src/features/pazarlama/components/program-duvari.tsx b/src/features/pazarlama/components/program-duvari.tsx
new file mode 100644
index 0000000..4e8f33c
--- /dev/null
+++ b/src/features/pazarlama/components/program-duvari.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+// Program duvarı (DESIGN.md §4.29): "24 tercih, binlerce ihtimal" cümlesinin
+// resmi. Küçük bir pencerede piksel kareler satır satır akar — her kare bir
+// program; aralarında tam 24 turuncu kare yerinde durur: senin listen.
+// İmleç yaklaştığı satırları hızlandırır. Veri taşıyan pikseldir (§1.2);
+// anlamı altındaki cümle söyler, renk tek başına değil (§2.2).
+//
+// Esin: React Bits "Squares Terminal" (yanıp sönen kare ızgarası, imlece
+// tepki veren satırlar). Kod bize ait: koyu CRT ekranı, parlama ve tüp eğimi
+// alınmadı; açık zemin, hero'nun ızgara ölçüleri (hücre 10, nokta 7.4, köşe 2)
+// ve doku opaklıkları kullanılır. Tek , yalnız ekrandayken ~30 fps;
+// hareketi azalt'ta durağan tek kare.
+
+import { useEffect, useRef } from "react";
+import { cn } from "@/lib/utils";
+
+const HUCRE = 10;
+const NOKTA = 7.4;
+const BOSLUK = (HUCRE - NOKTA) / 2;
+// Hero ile aynı doku paleti
+const OPAKLIKLAR = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
+const TERCIH = 24;
+/** İmlecin satırları etkilediği dikey uzaklık (px) ve en çok ek hız (hücre/sn). */
+const ETKI = 46;
+const EK_HIZ = 9;
+
+// Deterministik sözde-rastgele: sunucuda yok ama her ziyarette aynı desen
+const karma = (x: number, y: number) => {
+ const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
+ return n - Math.floor(n);
+};
+
+export function ProgramDuvari({
+ etiket,
+ className,
+}: {
+ /** Pencere çubuğundaki kısa yazı, ör. "21.000+ program". */
+ etiket: string;
+ className?: string;
+}) {
+ const kapRef = useRef(null);
+ const tuvalRef = useRef(null);
+
+ useEffect(() => {
+ const kap = kapRef.current;
+ const tuval = tuvalRef.current;
+ const ctx = tuval?.getContext("2d");
+ if (!kap || !tuval || !ctx) return;
+
+ const azalt = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ let en = 0;
+ let boy = 0;
+ let sutun = 0;
+ let satir = 0;
+ let mavi = "";
+ let turuncu = "";
+ /** Satır başına kayma (hücre) ve imleç kaynaklı ek hız. */
+ let kayma: number[] = [];
+ let ekHiz: number[] = [];
+ let tercihler: { c: number; r: number }[] = [];
+ let imlecY: number | null = null;
+ let gorunur = false;
+ let kare = 0;
+ let onceki = 0;
+ let basladi = 0;
+
+ const renkleriOku = () => {
+ // Tema değişince palet token'ları değişir (§2.3); renk CSS'ten okunur
+ mavi = getComputedStyle(tuval).color;
+ turuncu =
+ getComputedStyle(document.documentElement).getPropertyValue("--color-orange-500").trim() ||
+ "#f97316";
+ };
+
+ const olc = () => {
+ const kutu = kap.getBoundingClientRect();
+ const oran = Math.min(window.devicePixelRatio || 1, 2);
+ en = Math.round(kutu.width);
+ boy = Math.round(kutu.height);
+ tuval.width = en * oran;
+ tuval.height = boy * oran;
+ ctx.setTransform(oran, 0, 0, oran, 0, 0);
+ sutun = Math.ceil(en / HUCRE) + 1;
+ satir = Math.floor(boy / HUCRE);
+ kayma = Array.from({ length: satir }, (_, r) => kayma[r] ?? karma(r, 3) * 40);
+ ekHiz = Array.from({ length: satir }, () => 0);
+ // 24 tercih: kenarlardan bir hücre içeride, birbirine değmeyen sabit kareler
+ tercihler = [];
+ const dolu = new Set();
+ for (let i = 0; tercihler.length < TERCIH && i < 4000; i++) {
+ const c = 1 + Math.floor(karma(i, 17) * Math.max(1, sutun - 3));
+ const r = 1 + Math.floor(karma(i, 29) * Math.max(1, satir - 2));
+ const yakin = [-1, 0, 1].some((dx) =>
+ [-1, 0, 1].some((dy) => dolu.has(`${c + dx}:${r + dy}`)),
+ );
+ if (yakin) continue;
+ dolu.add(`${c}:${r}`);
+ tercihler.push({ c, r });
+ }
+ };
+
+ const kareCiz = (x: number, y: number) => {
+ ctx.beginPath();
+ if (ctx.roundRect) ctx.roundRect(x, y, NOKTA, NOKTA, 2);
+ else ctx.rect(x, y, NOKTA, NOKTA);
+ ctx.fill();
+ };
+
+ const ciz = (t: number) => {
+ ctx.clearRect(0, 0, en, boy);
+ const ustBosluk = (boy - satir * HUCRE) / 2;
+ ctx.fillStyle = mavi;
+ for (let r = 0; r < satir; r++) {
+ const tam = Math.floor(kayma[r]);
+ const kesir = kayma[r] - tam;
+ // Tek satırlar sola, çift satırlar sağa akar
+ const yon = r % 2 === 0 ? 1 : -1;
+ for (let c = -1; c < sutun; c++) {
+ const kimlik = c - yon * tam;
+ const h = karma(kimlik, r);
+ // Karelerin üçte biri boş: duvar değil doku
+ if (h < 0.34) continue;
+ const taban = OPAKLIKLAR[Math.abs(kimlik * 7 + r * 11) % OPAKLIKLAR.length];
+ // Yavaş nefes: her kare kendi fazında hafifçe parlar ve söner
+ const nefes = 0.6 + 0.4 * Math.sin(t * 0.9 + h * 6.283);
+ ctx.globalAlpha = taban * nefes;
+ kareCiz((c + yon * kesir) * HUCRE + BOSLUK, ustBosluk + r * HUCRE + BOSLUK);
+ }
+ }
+ // Tercihler: sırayla belirir (110 ms arayla), sonra yerinde kalır
+ ctx.fillStyle = turuncu;
+ const gecen = azalt ? Infinity : t - basladi;
+ tercihler.forEach(({ c, r }, i) => {
+ const yas = gecen - 0.4 - i * 0.11;
+ if (yas <= 0) return;
+ const giris = Math.min(1, yas / 0.3);
+ const x = c * HUCRE + BOSLUK;
+ const y = ustBosluk + r * HUCRE + BOSLUK;
+ // Altındaki akan kareyi sil: turuncu kare tek başına okunur
+ ctx.globalAlpha = 1;
+ ctx.clearRect(c * HUCRE, ustBosluk + r * HUCRE, HUCRE, HUCRE);
+ ctx.globalAlpha = 0.9 * giris;
+ kareCiz(x, y);
+ });
+ ctx.globalAlpha = 1;
+ };
+
+ const adim = (simdi: number) => {
+ kare = requestAnimationFrame(adim);
+ // ~30 fps yeter: kareler yavaş akar
+ if (simdi - onceki < 32) return;
+ const dt = Math.min(0.1, (simdi - onceki) / 1000);
+ onceki = simdi;
+ const ustBosluk = (boy - satir * HUCRE) / 2;
+ for (let r = 0; r < satir; r++) {
+ const merkez = ustBosluk + r * HUCRE + HUCRE / 2;
+ const yakinlik =
+ imlecY == null ? 0 : Math.max(0, 1 - Math.abs(merkez - imlecY) / ETKI);
+ // Ek hız hedefe yumuşak yaklaşır; imleç çekilince yavaşça söner
+ ekHiz[r] += (yakinlik * yakinlik * EK_HIZ - ekHiz[r]) * Math.min(1, dt * 5);
+ kayma[r] += (0.5 + karma(r, 7) * 1.3 + ekHiz[r]) * dt;
+ }
+ ciz(simdi / 1000);
+ };
+
+ const baslat = () => {
+ if (kare || azalt || !gorunur) return;
+ onceki = performance.now();
+ if (!basladi) basladi = onceki / 1000;
+ kare = requestAnimationFrame(adim);
+ };
+ const durdur = () => {
+ cancelAnimationFrame(kare);
+ kare = 0;
+ };
+
+ renkleriOku();
+ olc();
+ ciz(0);
+
+ const boyut = new ResizeObserver(() => {
+ olc();
+ ciz(performance.now() / 1000);
+ });
+ boyut.observe(kap);
+ const gorunum = new IntersectionObserver(([kayit]) => {
+ gorunur = kayit.isIntersecting;
+ if (gorunur) baslat();
+ else durdur();
+ });
+ gorunum.observe(kap);
+ const tema = new MutationObserver(() => {
+ renkleriOku();
+ ciz(performance.now() / 1000);
+ });
+ tema.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+
+ const imlec = (e: PointerEvent) => {
+ if (e.pointerType !== "mouse") return;
+ const kutu = kap.getBoundingClientRect();
+ const icinde =
+ e.clientX >= kutu.left &&
+ e.clientX <= kutu.right &&
+ e.clientY >= kutu.top - ETKI &&
+ e.clientY <= kutu.bottom + ETKI;
+ imlecY = icinde ? e.clientY - kutu.top : null;
+ };
+ window.addEventListener("pointermove", imlec, { passive: true });
+
+ return () => {
+ durdur();
+ boyut.disconnect();
+ gorunum.disconnect();
+ tema.disconnect();
+ window.removeEventListener("pointermove", imlec);
+ };
+ }, []);
+
+ return (
+
+ {/* Mini pencere: aramadaki tarayıcı penceresiyle aynı dil (§4.20) */}
+
+
+
+
+
+ {etiket}
+
+
+
+
+
+
+
+ Her kare bir program; turuncular senin 24 tercihin.
+
+
+ );
+}
diff --git a/src/features/pazarlama/components/suzulen-haplar.tsx b/src/features/pazarlama/components/suzulen-haplar.tsx
new file mode 100644
index 0000000..8abd4a7
--- /dev/null
+++ b/src/features/pazarlama/components/suzulen-haplar.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+// Süzülen haplar (DESIGN.md §4.30): kataloğun en çok aranan bölüm ve şehir
+// sayfalarına giden haplar. Bölüm ekrana girince haplar yukarıdan hafif
+// eğik düşüp yerine oturur; imleç gezdikçe her hap kendi derinliğinde azıcık
+// süzülür, arkadaki piksel kareler ters yöne kayar. Her hap gerçek bir
+// linktir (§4.12 kimlik hapı, hover tek turuncu).
+//
+// Esin: React Bits "Parallax Pills" (imleci izleyen, sekerek giren etiketli
+// haplar). Kod bize ait: haplar mutlak konumla değil akışla dizilir (375 px'te
+// üst üste binmez), giriş tek ve hafif sekmeli (düşen nesne, §6), arka plan
+// süs hapı yerine sitenin piksel kareleri. Dokunmatikte süzülme yoktur;
+// hareketi azalt'ta haplar yalnız belirir.
+
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import {
+ motion,
+ useMotionValue,
+ useReducedMotion,
+ useSpring,
+ useTransform,
+ type MotionValue,
+} from "motion/react";
+import { GraduationCap, MapPin } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { KatalogHapi } from "@/features/pazarlama/landing-verisi";
+
+/** İmlecin kenardan kenara taşıdığı en büyük kayma (px). */
+const GUC = 18;
+const SUZULME_YAYI = { stiffness: 120, damping: 20, mass: 0.6 };
+/** Düşüş: yukarıdan gelir, tek kez hafif seker. */
+const DUSUS = { type: "spring", duration: 0.7, bounce: 0.22 } as const;
+
+const karma = (i: number, tohum: number) => {
+ const n = Math.sin(i * 127.1 + tohum * 311.7) * 43758.5453;
+ return n - Math.floor(n);
+};
+
+// Arkadaki piksel kareler: kenarlarda, orta boş (§10.8 doku kuralı). Konum %.
+const KARELER = [
+ { x: 4, y: 12, boy: 27, sicak: false },
+ { x: 13, y: 78, boy: 17, sicak: true },
+ { x: 24, y: 2, boy: 17, sicak: false },
+ { x: 77, y: 0, boy: 27, sicak: false },
+ { x: 90, y: 22, boy: 17, sicak: true },
+ { x: 94, y: 70, boy: 37, sicak: false },
+ { x: 70, y: 90, boy: 17, sicak: false },
+ { x: 0, y: 52, boy: 17, sicak: false },
+];
+
+type Durum = "ssr" | "bekliyor" | "geldi";
+
+function Hap({
+ hap,
+ sira,
+ durum,
+ azalt,
+ mx,
+ my,
+}: {
+ hap: KatalogHapi;
+ sira: number;
+ durum: Durum;
+ azalt: boolean;
+ mx: MotionValue;
+ my: MotionValue;
+}) {
+ // Her hap kendi derinliğinde: öndekiler çok, arkadakiler az kayar
+ const derinlik = 0.45 + karma(sira, 5) * 0.75;
+ const x = useTransform(mx, (v) => v * GUC * derinlik);
+ const y = useTransform(my, (v) => v * GUC * 0.7 * derinlik);
+ const egim = (karma(sira, 9) - 0.5) * 16;
+ const iri = sira % 3 === 0;
+ const Ikon = hap.tur === "sehir" ? MapPin : GraduationCap;
+ const gizli = azalt ? { opacity: 0 } : { opacity: 0, y: -36, rotate: egim };
+
+ return (
+
+
+
+
+ {hap.etiket}
+
+ {hap.tur === "sehir" ? " üniversiteleri" : " taban puanları"}
+
+
+
+
+ );
+}
+
+function ArkaKare({
+ kare,
+ mx,
+ my,
+}: {
+ kare: (typeof KARELER)[number];
+ mx: MotionValue;
+ my: MotionValue;
+}) {
+ // Arka katman ters yöne ve daha çok kayar: derinlik hissi
+ const x = useTransform(mx, (v) => -v * GUC * 1.4);
+ const y = useTransform(my, (v) => -v * GUC);
+ return (
+
+ );
+}
+
+export function SuzulenHaplar({
+ haplar,
+ className,
+}: {
+ haplar: KatalogHapi[];
+ className?: string;
+}) {
+ const kapRef = useRef(null);
+ const azalt = useReducedMotion() ?? false;
+ // SSR'da haplar görünür çizilir (JS'siz ve bot için). Montajdan sonra bölüm
+ // henüz ekranda değilse gizlenir, girince düşer; zaten ekrandaysa dokunulmaz.
+ const [durum, setDurum] = useState("ssr");
+ const hamX = useMotionValue(0);
+ const hamY = useMotionValue(0);
+ const mx = useSpring(hamX, SUZULME_YAYI);
+ const my = useSpring(hamY, SUZULME_YAYI);
+
+ useEffect(() => {
+ const kap = kapRef.current;
+ if (!kap || typeof IntersectionObserver === "undefined") return;
+ let ilk = true;
+ const io = new IntersectionObserver(
+ ([kayit]) => {
+ if (kayit.isIntersecting) {
+ setDurum((d) => (d === "bekliyor" ? "geldi" : d));
+ io.disconnect();
+ } else if (ilk) {
+ setDurum("bekliyor");
+ }
+ ilk = false;
+ },
+ { rootMargin: "0px 0px -80px 0px" },
+ );
+ io.observe(kap);
+ return () => io.disconnect();
+ }, []);
+
+ // İmleç: yalnız fare; kabın ortasına göre −0,5…0,5
+ useEffect(() => {
+ const kap = kapRef.current;
+ if (!kap || azalt || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
+ return;
+ }
+ const oynadi = (e: PointerEvent) => {
+ const kutu = kap.getBoundingClientRect();
+ hamX.set(Math.max(-0.5, Math.min(0.5, (e.clientX - kutu.left) / kutu.width - 0.5)));
+ hamY.set(Math.max(-0.5, Math.min(0.5, (e.clientY - kutu.top) / kutu.height - 0.5)));
+ };
+ const cikti = () => {
+ hamX.set(0);
+ hamY.set(0);
+ };
+ kap.addEventListener("pointermove", oynadi, { passive: true });
+ kap.addEventListener("pointerleave", cikti);
+ return () => {
+ kap.removeEventListener("pointermove", oynadi);
+ kap.removeEventListener("pointerleave", cikti);
+ };
+ }, [azalt, hamX, hamY]);
+
+ return (
+
+
+ {KARELER.map((kare) => (
+
+ ))}
+
+
+ {haplar.map((hap, i) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/pazarlama/components/veri-akisi.tsx b/src/features/pazarlama/components/veri-akisi.tsx
new file mode 100644
index 0000000..b9bc36c
--- /dev/null
+++ b/src/features/pazarlama/components/veri-akisi.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+// Veri akışı (DESIGN.md §4.28): listenin neyle kurulduğunu tek bakışta
+// gösterir. Ortada KolayTercih işareti, çevresinde altı girdi; her girdiden
+// merkeze düz bir çizgi iner, üzerinde piksel kare akar. Soldaki üçü
+// öğrencinin seçimi (turuncu), sağdaki üçü YÖK Atlas verisi (mavi) — ayrımı
+// renk değil, etiket ve yan taşır; renk yalnız eşlik eder (§2.2).
+//
+// Kartsız ve zeminsizdir (Bilal, 7 Eki 2026): sayfa zemininde açıkta durur,
+// eskiden altında duran sayılar ve dürüstlük notu geniş ekranda iki yanına
+// dizilir (yerleşim app/page.tsx'te). Ortadaki işaret site başlığındaki
+// logonun animasyonunu oynar: kareler sırayla birbirinin yerine süzülür
+// (`AnimasyonluMark` "rota").
+//
+// Esin: React Bits "Center Flow" (merkezden çevreye akan çizgiler). Kod bize
+// ait: parlama/bulanıklık yok (§2.2), akış yönü içeri doğrudur ve yalnız
+// ekrandayken oynar.
+
+import { useEffect, useRef, useState, type CSSProperties } from "react";
+import { Compass, MapPin, PieChart, TrendingUp, Trophy, Users } from "lucide-react";
+import { AnimasyonluMark } from "@/components/animasyonlu-mark";
+import { cn } from "@/lib/utils";
+
+// Sağ yarı (−60°, 0°, 60°) veri; sol yarı (120°, 180°, 240°) öğrencinin seçimi.
+const DUGUMLER = [
+ { etiket: "Taban sıralamaları", ikon: TrendingUp, aci: -60, kaynak: "veri" },
+ { etiket: "Kontenjanlar", ikon: Users, aci: 0, kaynak: "veri" },
+ { etiket: "Doluluk oranları", ikon: PieChart, aci: 60, kaynak: "veri" },
+ { etiket: "Şehir tercihin", ikon: MapPin, aci: 120, kaynak: "sen" },
+ { etiket: "Senin sıralaman", ikon: Trophy, aci: 180, kaynak: "sen" },
+ { etiket: "İlgi alanların", ikon: Compass, aci: 240, kaynak: "sen" },
+] as const;
+
+/** viewBox 100×100: düğümlerin merkeze uzaklığı. Çizgi merkezden merkeze
+ * çekilir ve karoların altında kalır; akan kare ise karoların dışında
+ * kalan aralıkta yürür (iki uçtaki boşluk). */
+const YARICAP = 36;
+const DIS_BOSLUK = 9.5;
+const IC_BOSLUK = 15.5;
+/** Akan karenin kenarı (hero noktasının viewBox karşılığı). */
+const KARE = 2.4;
+
+const nokta = (aci: number, r: number) => {
+ const rad = (aci * Math.PI) / 180;
+ return { x: 50 + Math.cos(rad) * r, y: 50 + Math.sin(rad) * r };
+};
+
+export function VeriAkisi({ className }: { className?: string }) {
+ const kapRef = useRef(null);
+ const [oynuyor, setOynuyor] = useState(false);
+
+ // Ekran dışındayken akış da işaret de durur (kareler CSS'te `data-oynuyor`
+ // ile, işaret `oynat` ile).
+ useEffect(() => {
+ const kap = kapRef.current;
+ if (!kap || typeof IntersectionObserver === "undefined") return;
+ const io = new IntersectionObserver(([kayit]) => {
+ setOynuyor(kayit.isIntersecting);
+ });
+ io.observe(kap);
+ return () => io.disconnect();
+ }, []);
+
+ return (
+
+
+
+ {DUGUMLER.map((d, i) => {
+ const dugum = nokta(d.aci, YARICAP);
+ const dis = nokta(d.aci, YARICAP - DIS_BOSLUK);
+ const ic = nokta(d.aci, IC_BOSLUK);
+ return (
+
+ {/* Düz çizgi: karodan merkeze; uçları karoların altında */}
+
+
+
+ );
+ })}
+
+
+ {DUGUMLER.map((d) => {
+ const p = nokta(d.aci, YARICAP);
+ const ustte = d.aci < 0 || d.aci > 180;
+ return (
+
+ {/* Beyaz, dolu karo (çizgi altından geçer); yanı ikonun rengi söyler */}
+
+
+
+ {/* Üstteki iki düğümde etiket karonun üstünde: altında çizgi merkeze iniyor */}
+
+ {d.etiket}
+
+
+ );
+ })}
+
+
+ {/* Site başlığındaki logonun animasyonu (rota): orada üzerine
+ gelince, burada ekrandayken sürekli oynar. */}
+
+
+
+
+ Solda senin seçimlerin, sağda YÖK Atlas verisi. Yapay zekâ yalnız bu
+ ikisinin kesiştiği programlardan seçer.
+
+
+ );
+}
diff --git a/src/features/pazarlama/landing-verisi.ts b/src/features/pazarlama/landing-verisi.ts
new file mode 100644
index 0000000..00c733e
--- /dev/null
+++ b/src/features/pazarlama/landing-verisi.ts
@@ -0,0 +1,129 @@
+import "server-only";
+
+// Ana sayfadaki iki etkileşimli parçanın verisi (sunucuda, build/istek anında):
+// - Kart destesi demosu (DESIGN.md §4.27): örnek bir sıralamaya göre GERÇEK
+// programlar. Uydurma taban sıralaması gösterilmez; veri okunamazsa bölüm
+// hiç çizilmez.
+// - Süzülen haplar (§4.30): yalnız katalogda sayfası olan bölüm ve şehirler.
+
+import { dilimAra } from "@/lib/db";
+import { bolumSlugFromIsim, getIlBySlug } from "@/lib/katalog";
+import { ozelKontenjanSatiriMi } from "@/lib/rapor-havuzu";
+import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
+import { bolumBazAdi, ilSayfaHref, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
+import type { DilimKey } from "@/types/yokatlas";
+
+/** Demonun örnek öğrencisi: Sayısal, 85.000. sıra (§10.1'deki örnekle aynı). */
+export const ORNEK_SIRA = 85_000;
+const ORNEK_TUR = "say" as const;
+
+export interface OrnekProgram {
+ id: string;
+ isim: string;
+ universite: string;
+ il: string | null;
+ tabanSira: number;
+ risk: RiskSeviyesi;
+}
+
+/**
+ * Her dilimden birkaç program: sınıra yapışık olanlar yerine dilimin içine
+ * yayılmış (riskli / az riskli / güvenli farkı kartta görünsün), her üniversite
+ * ve her bölüm adı en çok bir kez.
+ */
+export function ornekProgramlar(): OrnekProgram[] {
+ try {
+ const gorulenUni = new Set();
+ const gorulenBolum = new Set();
+ const sec = (dilim: DilimKey, adet: number, adim: number): OrnekProgram[] => {
+ const { programlar } = dilimAra(ORNEK_SIRA, ORNEK_TUR, dilim, {
+ uniturGrubu: "devlet",
+ barajDisiHaric: true,
+ limit: 600,
+ });
+ const secilen: OrnekProgram[] = [];
+ for (let i = adim; i < programlar.length && secilen.length < adet; i += adim) {
+ const p = programlar[i];
+ const bolum = bolumBazAdi(p.isim);
+ const risk = riskHesapla(p.efektifSira, ORNEK_SIRA);
+ if (
+ p.efektifSira == null ||
+ !risk ||
+ ozelKontenjanSatiriMi(p.isim) ||
+ /\(/.test(p.isim) ||
+ gorulenUni.has(p.universite) ||
+ gorulenBolum.has(bolum)
+ ) {
+ // Elenen satırın bir sonrakine bak; adım bozulmasın diye geri al
+ i -= adim - 1;
+ continue;
+ }
+ gorulenUni.add(p.universite);
+ gorulenBolum.add(bolum);
+ secilen.push({
+ id: p.id,
+ isim: p.isim,
+ universite: trBaslikDuzeni(p.universite),
+ il: p.il ? trBaslikDuzeni(p.il) : null,
+ tabanSira: p.efektifSira,
+ risk,
+ });
+ }
+ return secilen;
+ };
+ const hayal = sec("hayal", 2, 60);
+ const dengeli = sec("dengeli", 3, 45);
+ const guvenli = sec("garanti", 2, 90);
+ // Sıra karışık: aynı risk art arda gelmesin
+ return [dengeli[0], hayal[0], guvenli[0], dengeli[1], hayal[1], dengeli[2], guvenli[1]].filter(
+ (p): p is OrnekProgram => Boolean(p),
+ );
+ } catch {
+ return [];
+ }
+}
+
+export interface KatalogHapi {
+ etiket: string;
+ href: string;
+ tur: "bolum" | "sehir";
+}
+
+const HAP_BOLUMLERI = [
+ "Tıp",
+ "Hukuk",
+ "Bilgisayar Mühendisliği",
+ "Psikoloji",
+ "Diş Hekimliği",
+ "Hemşirelik",
+ "Mimarlık",
+ "Yazılım Mühendisliği",
+ "Eczacılık",
+ "İşletme",
+];
+const HAP_SEHIRLERI = ["İstanbul", "Ankara", "İzmir", "Eskişehir", "Bursa", "Antalya"];
+
+/** Bölüm ve şehir hapları; katalogda karşılığı olmayan ad sessizce düşer. */
+export function katalogHaplari(): KatalogHapi[] {
+ try {
+ const bolumler: KatalogHapi[] = HAP_BOLUMLERI.flatMap((ad) => {
+ const slug = bolumSlugFromIsim(ad);
+ return slug ? [{ etiket: ad, href: `/bolum/${slug}`, tur: "bolum" as const }] : [];
+ });
+ const sehirler: KatalogHapi[] = HAP_SEHIRLERI.flatMap((ad) =>
+ getIlBySlug(turkishSlugify(ad))
+ ? [{ etiket: ad, href: ilSayfaHref(ad), tur: "sehir" as const }]
+ : [],
+ );
+ // Bölüm ve şehir karışık dizilsin: her üç bölümden sonra bir şehir
+ const sonuc: KatalogHapi[] = [];
+ let s = 0;
+ bolumler.forEach((b, i) => {
+ sonuc.push(b);
+ if (i % 2 === 1 && s < sehirler.length) sonuc.push(sehirler[s++]);
+ });
+ return [...sonuc, ...sehirler.slice(s)];
+ } catch {
+ return [];
+ }
+}
diff --git a/src/features/rapor/components/tercih-degisti-modali.tsx b/src/features/rapor/components/tercih-degisti-modali.tsx
index 79b4e52..68f226c 100644
--- a/src/features/rapor/components/tercih-degisti-modali.tsx
+++ b/src/features/rapor/components/tercih-degisti-modali.tsx
@@ -27,7 +27,7 @@
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
diff --git a/src/features/sihirbaz/components/cta-sira-form.tsx b/src/features/sihirbaz/components/cta-sira-form.tsx
index e2f3d26..caede74 100644
--- a/src/features/sihirbaz/components/cta-sira-form.tsx
+++ b/src/features/sihirbaz/components/cta-sira-form.tsx
@@ -8,12 +8,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SecimHaplari } from "@/components/ui/secim-haplari";
import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerTitle,
-} from "@/components/ui/drawer";
+ AltSayfa,
+ AltSayfaKapat,
+ AltSayfaGovde,
+ AltSayfaIcerik,
+ AltSayfaAciklama,
+ AltSayfaBaslik,
+} from "@/components/ui/alt-sayfa";
import { PikselZemin } from "@/components/piksel-zemin";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import {
@@ -305,19 +306,15 @@ export function CtaSiraForm({
// başlığı yalnız ekran okuyucuya.
const siraAdimi = !kompakt && adim === "sira";
const cekmece = (
-
-
-
+
+
+
Kapat
-
+
-
-
+
Sıralamana yakın programlar hangileri?
-
-
+
+
{siraAdimi ? (
{siraFormu}
) : facetler && sira != null ? (
@@ -351,9 +348,9 @@ export function CtaSiraForm({
onTamamla={secimleriTamamla}
/>
) : null}
-
-
-
+
+
+
);
if (kompakt) {
diff --git a/src/features/sihirbaz/components/hero-form.tsx b/src/features/sihirbaz/components/hero-form.tsx
index b9fa993..397d107 100644
--- a/src/features/sihirbaz/components/hero-form.tsx
+++ b/src/features/sihirbaz/components/hero-form.tsx
@@ -20,12 +20,9 @@ import {
import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
-import {
- profilDuzenlemeyiAc,
- useTercihProfili,
-} from "@/features/liste/hooks/use-tercih-profili";
+import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas";
-import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
+import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
@@ -107,11 +104,10 @@ export function HeroForm() {
}
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
- // "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
- // (TercihProfiliKapisi, layout'ta monte) açar.
+ // Sıralamayı değiştirmek navbar'daki sıralama hapından yapılır.
if (kayitliProfil) {
return (
-
+
-
- {kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
- {PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
- devam edersin.{" "}
-
- Farklı sıralama gir
-
-
);
}
diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
index 2402443..864c6bd 100644
--- a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
+++ b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx
@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
-import { toast } from "sonner";
+import { toast } from "@/components/ui/bildirim";
import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
@@ -23,47 +23,8 @@ import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler";
import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum";
-
-export function Cip({
- secili,
- onClick,
- children,
- kucuk = false,
- ton = "notr",
- className = "",
-}: {
- secili: boolean;
- onClick: () => void;
- children: React.ReactNode;
- kucuk?: boolean;
- /**
- * bilgi: özel kontenjan durumu çipi (DESIGN.md §4.21) — pasif hâli bilgi
- * mavisi tonunda (§2 `primary` rolü), seçili hâli nötr çiple aynı.
- */
- ton?: "notr" | "bilgi";
- className?: string;
-}) {
- // Şişko ve sade (DESIGN.md §1.1): çip 44 px dokunma hedefi; il
- // çipleri (kucuk) çok sayıda olduğu için 40 px. Hover §2.1 tek turuncu.
- const boyut = kucuk ? "h-10 px-3.5 text-sm" : "h-11 px-4 text-sm";
- const temel = `inline-flex cursor-pointer items-center rounded-full border font-semibold transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] ${boyut} ${className}`;
- const pasif =
- ton === "bilgi"
- ? "border-primary/30 bg-primary/10 text-primary hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"
- : "border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500";
- return (
-
- {children}
-
- );
-}
+// Seçim çipi tek kaynaktan (DESIGN.md §4.24): seçilince tik büyür, yazı kayar.
+import { CipGrubu, SecimCipi as Cip } from "@/components/ui/cip-grubu";
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
@@ -337,7 +298,7 @@ export function SihirbazAdimlar({
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
-
+
{facetler.kategoriler.map((k) => (
{k.adet}
))}
-
+
@@ -387,8 +348,9 @@ export function SihirbazAdimlar({
görünüm ilk 10 il; kalanı "+N il daha" rozetiyle açılır. Arama
yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
devre dışı bırakır. */}
-
@@ -424,7 +386,7 @@ export function SihirbazAdimlar({
Daha az göster
) : null}
-
+
{ilAramaNorm &&
gorunenTurkiyeIlleri.length === 0 &&
@@ -439,8 +401,9 @@ export function SihirbazAdimlar({
KKTC & Yurt Dışı
-
@@ -455,7 +418,7 @@ export function SihirbazAdimlar({
{i.adet}
))}
-
+
) : null}
@@ -500,8 +463,9 @@ export function SihirbazAdimlar({
Üniversite tipi
-
@@ -525,7 +489,7 @@ export function SihirbazAdimlar({
>
Fark etmez
-
+
@@ -533,7 +497,7 @@ export function SihirbazAdimlar({
{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda
yedi çip 44 px'le pencereyi 812 px'in dışına itiyordu. */}
-
+
{ONCELIKLER.map((o) => (
))}
-
+
Listeyi daraltmaz; programların sırasını ve gerekçelerini
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
@@ -559,7 +523,7 @@ export function SihirbazAdimlar({
Özel kontenjan durumun{" "}
(isteğe bağlı)
-
+
{OZEL_DURUMLAR.map((d) => (
))}
-
+
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
kontenjanı olan programlar listende öne çıkar ve danışman bunu
diff --git a/src/lib/hareket.ts b/src/lib/hareket.ts
new file mode 100644
index 0000000..9052fea
--- /dev/null
+++ b/src/lib/hareket.ts
@@ -0,0 +1,48 @@
+// Arc UI'dan (uiarc.dev) taşınan bileşenlerin ortak hareket sabitleri.
+// Arc'ın `motionTokens`'ının DESIGN.md §6'ya göre ayarlı hâli: yaylar
+// sekmesizdir; sekme yalnız kullanıcı bir şeyi bıraktığında/fırlattığında
+// (`birakma`) vardır. Yeni bir Arc bileşeni taşınırken yay buradan alınır.
+
+export const YAY = {
+ /** §6 varsayılanı: vurgu kayması, sayı rulosu, panel geçişi. */
+ varsayilan: { type: "spring", bounce: 0, duration: 0.35 },
+ /** Panel, yükseklik, yığın yerleşimi. Arc "smooth". */
+ akici: { type: "spring", visualDuration: 0.4, bounce: 0 },
+ /** Genişlik/biçim dönüşümü (çip, yazı). Arc "morph", sekmesi alınmış. */
+ donusum: { type: "spring", visualDuration: 0.42, bounce: 0 },
+ /** Parmak bıraktıktan sonraki yerine oturma. Arc "snappy": tek yerde hafif sekme. */
+ birakma: { type: "spring", visualDuration: 0.26, bounce: 0.12 },
+} as const;
+
+type Egri = [number, number, number, number];
+
+export const EGRI: { giris: Egri; standart: Egri } = {
+ /** Ekrana giren öğe: hızlı başlar, yumuşak durur. */
+ giris: [0.16, 1, 0.3, 1],
+ /** Ekrandayken değişen renk/opaklık. */
+ standart: [0.22, 1, 0.36, 1],
+};
+
+export const SURE = { anlik: 0.12, hizli: 0.16, cikis: 0.18, standart: 0.24 } as const;
+
+/**
+ * Sınırın ötesine çekilen öğenin direnci (iOS lastik bandı): parmağı izler
+ * ama her piksel bir öncekinden pahalıdır, `olcu`ya yaklaşır ve geçmez.
+ */
+export function lastik(tasma: number, olcu: number): number {
+ return (1 - 1 / ((tasma * 0.55) / olcu + 1)) * olcu;
+}
+
+/** `lastik`in tersi: ekrandaki esnemeden parmağın gerçek yolunu geri verir. */
+export function lastikTersi(esneme: number, olcu: number): number {
+ return ((1 / (1 - Math.min(esneme, olcu - 1) / olcu) - 1) * olcu) / 0.55;
+}
+
+/** Son ~80 ms'lik örneklerden bırakma hızı (px/sn); bırakmadan önce duran parmak 0 verir. */
+export function birakmaHizi(ornekler: { t: number; v: number }[], simdi: number): number {
+ const yakin = ornekler.filter((o) => simdi - o.t <= 80);
+ const ilk = yakin[0];
+ const son = yakin[yakin.length - 1];
+ if (!ilk || !son || ilk === son || simdi - son.t > 60) return 0;
+ return (son.v - ilk.v) / ((son.t - ilk.t) / 1000);
+}