feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını

Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-07 17:12:53 +03:00
parent bc920f6111
commit 3c995402c6
38 changed files with 5474 additions and 697 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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<yıl>` 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.

View File

@@ -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"], /<SectionEyebrow\b/),
bul: blokIcinde(["AltSayfaIcerik", "DialogContent"], /<SectionEyebrow\b/),
},
{
ad: "cta-yildiz",

View File

@@ -449,30 +449,38 @@
}
}
/* Bildirim (toast, sonner) — DESIGN.md §4.16. Başlık ve açıklama slate
tonlarında okunur; aksiyon düğmesi üst çubuktaki koyu hap dilinde.
".toaster" öneki şart: sonner kendi stilini çalışma anında enjekte eder,
aynı özgüllükteki kural onun altında kalır. */
.toaster [data-sonner-toast][data-styled="true"] {
border-radius: 1rem;
border-color: var(--color-slate-200);
/* Veri akışı (veri-akisi.tsx, DESIGN.md §4.28): kare, çizgi boyunca karodan
merkeze akar. Uç noktalar satır içi --x1/--y1/--x2/--y2 ile gelir (SVG
içinde px = viewBox birimi). Yalnız ekrandayken oynar. */
@keyframes kt-akis {
0% {
translate: var(--x1) var(--y1);
opacity: 0;
}
12%,
82% {
opacity: 1;
}
100% {
translate: var(--x2) var(--y2);
opacity: 0;
}
}
.toaster [data-sonner-toast][data-styled="true"] [data-title] {
font-weight: 600;
color: var(--color-slate-900);
.kt-akis-kare {
translate: var(--x1) var(--y1);
opacity: 0;
animation: kt-akis 2.4s cubic-bezier(0.45, 0, 0.55, 1) infinite;
animation-play-state: paused;
}
.toaster [data-sonner-toast][data-styled="true"] [data-description] {
color: var(--color-slate-600);
[data-oynuyor] .kt-akis-kare {
animation-play-state: running;
}
.toaster [data-sonner-toast][data-styled="true"] [data-button] {
height: 2.25rem;
padding-inline: 0.875rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-slate-50);
background: var(--color-slate-900);
}
.toaster [data-sonner-toast][data-styled="true"] [data-button]:hover {
background: var(--color-slate-700);
@media (prefers-reduced-motion: reduce) {
.kt-akis-kare {
animation: none;
}
}

View File

@@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/sonner";
import { BildirimYigini } from "@/components/ui/bildirim";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
@@ -129,7 +129,7 @@ export default function RootLayout({
<TercihProfiliKapisiLazy />
{/* İlk ziyarette bir kez: çerez bilgilendirmesi (onay penceresi değil) */}
<CerezBilgi />
<Toaster position="top-center" />
<BildirimYigini />
{/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */}
<ConsoleAsr />
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}

View File

@@ -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 (
<div
id="top"
@@ -257,27 +296,6 @@ export default function Home() {
>
<HeroForm />
</div>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-card px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
<span>
<strong className="font-semibold text-slate-900">
Veri: YÖK Atlas
</strong>{" "}
· son güncelleme{" "}
<time dateTime={KATALOG_VERI_TARIHI}>
{KATALOG_TARIH_METNI}
</time>
</span>
</div>
{/* 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). */}
<p className="mt-5 max-w-2xl text-sm leading-6 text-slate-600">
KolayTercih, YÖK Atlas&apos;ı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.
</p>
</Parallax>
</section>
@@ -303,23 +321,24 @@ export default function Home() {
</p>
</Reveal>
</Parallax>
{/* 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. */}
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
{problems.map((p, i) => (
<Reveal key={p.title} delay={i * 0.08}>
<Card className="relative h-full overflow-hidden border-slate-200/70 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<PixelCorner className="absolute right-4 top-4" />
<CardHeader>
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10">
<p.icon className="size-5 text-primary" aria-hidden />
<Reveal key={p.title} delay={i * 0.08} className="h-full">
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm transition-[translate,border-color,box-shadow] duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<div className="border-b border-slate-100 bg-slate-50 px-6 py-8">
<PixelSahne izgara={p.sahne} />
</div>
<CardTitle className="font-heading text-xl">
<div className="p-6">
<h3 className="font-heading text-xl font-bold text-balance">
{p.title}
</CardTitle>
<CardDescription className="text-base leading-relaxed">
</h3>
<p className="mt-2 text-base leading-relaxed text-slate-600">
{p.description}
</CardDescription>
</CardHeader>
</Card>
</p>
</div>
</div>
</Reveal>
))}
</Parallax>
@@ -361,78 +380,83 @@ export default function Home() {
</p>
</Reveal>
</Parallax>
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
{/* 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. */}
<Parallax strength={7} className="mt-12">
<ol className="grid gap-12 md:grid-cols-3 md:gap-8">
{steps.map((s, i) => (
<Reveal key={s.step} delay={i * 0.08} className="h-full">
<div className="relative h-full rounded-2xl border border-slate-200/70 bg-card p-8 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<span className="absolute -top-4 left-8 flex size-8 items-center justify-center rounded-full bg-orange-500 font-heading text-sm font-bold text-white">
{s.step}
</span>
<PixelCorner className="absolute right-4 top-4" />
<s.icon className="size-6 text-primary" aria-hidden />
<h3 className="mt-4 font-heading text-xl font-semibold">
<li key={s.step}>
<Reveal
delay={i * 0.08}
className="relative flex flex-col items-center text-center"
>
<PixelRakam rakam={s.step} className="h-24 w-auto" />
{i < steps.length - 1 ? (
<span
aria-hidden
className="absolute top-12 left-[calc(50%+4.5rem)] hidden h-0.5 w-[calc(100%-7rem)] -translate-y-1/2 rounded-full bg-slate-200 md:block"
/>
) : null}
<h3 className="mt-6 font-heading text-xl font-bold text-balance">
<span className="sr-only">{s.step}. adım: </span>
{s.title}
</h3>
<p className="mt-2 leading-relaxed text-slate-600">
<p className="mt-2 max-w-sm text-base leading-relaxed text-slate-600">
{s.description}
</p>
</div>
</Reveal>
</li>
))}
</ol>
</Parallax>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
<Parallax strength={10} className="mx-auto mt-12 w-full max-w-3xl">
{/* 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. */}
<Parallax strength={10} className="mt-20">
<Reveal
delay={0.1}
className="overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm"
className="grid items-center gap-12 lg:grid-cols-[minmax(0,1fr)_22rem_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[minmax(0,1fr)_24rem_minmax(0,1fr)] xl:gap-12"
>
<dl className="grid grid-cols-1 divide-y divide-slate-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{[
{
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) => (
<VeriAkisi className="lg:order-2" />
<dl className="mx-auto grid w-full max-w-3xl grid-cols-1 gap-8 text-center sm:grid-cols-3 lg:order-3 lg:mx-0 lg:grid-cols-1 lg:gap-10 lg:text-right">
{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.
<div
key={stat.label}
className="flex flex-col p-6 text-center"
>
<div key={stat.label} className="flex flex-col">
<dt className="order-2 mt-2 text-sm font-semibold text-slate-500">
{stat.label}
</dt>
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary">
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary sm:text-5xl">
{stat.value}
{stat.birim ? (
<span className="ml-1.5 font-sans text-base font-semibold tracking-normal text-slate-400">
{stat.birim}
</span>
) : null}
</dd>
</div>
))}
</dl>
<div className="flex gap-4 border-t border-slate-200 bg-primary/5 p-6 text-left sm:items-center sm:p-7">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-semibold text-primary">
Dürüstlük notu
</p>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl">
Yerleşme garantisi vermiyoruz — çünkü kimse veremez.
</p>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
<div className="mx-auto flex w-full max-w-xl flex-col items-center text-center lg:order-1 lg:mx-0 lg:max-w-xs lg:items-start lg:text-left">
<SectionEyebrow>Dürüstlük notu</SectionEyebrow>
<h3 className="mt-4 font-heading text-2xl font-bold text-balance text-slate-900">
Yerleşme garantisi vermiyoruz —{" "}
<span className="font-bricolage font-bold tracking-tighter text-primary italic">
çünkü kimse veremez.
</span>
</h3>
<p className="mt-3 text-base leading-relaxed text-slate-600">
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.
</p>
</div>
</div>
</Reveal>
</Parallax>
</div>
@@ -668,6 +692,47 @@ export default function Home() {
</div>
</section>
{/* Katalog: süzülen haplar (DESIGN.md §4.30) */}
{haplar.length >= 6 ? (
<>
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={41} />
</Parallax>
</div>
<section id="katalog" className="scroll-mt-24 py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
<SectionEyebrow>Katalog ücretsiz</SectionEyebrow>
<h2 className="font-heading text-3xl font-bold text-balance sm:text-4xl">
Önce bir bakmak ister misin?
</h2>
<p className="max-w-xl text-lg leading-relaxed text-slate-600">
Taban sıralamaları ve kontenjanlar herkese açık. Üye
olmadan bölüm bölüm, şehir şehir gez.
</p>
</Reveal>
</Parallax>
<SuzulenHaplar haplar={haplar} className="mt-6" />
<div className="mt-4 flex justify-center">
<Button
asChild
size="lg"
variant="outline"
className="cursor-pointer px-6 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
>
<Link href="/bolumler">
Tüm bölümleri gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
</div>
</section>
</>
) : null}
{/* Fiyat → SSS geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>

View File

@@ -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 {

View File

@@ -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<SVGSVGElement>(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(
<rect
key={`${x}-${y}`}
x={x * CELL + INSET}
y={y * CELL + INSET}
width={DOT}
height={DOT}
rx={2}
data-px=""
data-cx={x * CELL + CELL / 2}
data-cy={y * CELL + CELL / 2}
data-op={op}
fill="currentColor"
fillOpacity={op}
/>,
);
}
}
});
return sonuc;
}, [izgara, ton]);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${cols * CELL} ${izgara.length * CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-auto w-full text-primary", className)}
>
{pixels}
</svg>
);
}
// 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 (
<PixelSahne
izgara={RAKAMLAR[rakam]}
ton="dolgun"
yaricap={22}
className={className}
/>
);
}
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({
children,

View File

@@ -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<HTMLDivElement>) => void;
onPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => void;
onPointerUp: (e: ReactPointerEvent<HTMLDivElement>) => void;
onPointerCancel: (e: ReactPointerEvent<HTMLDivElement>) => void;
};
const SayfaBaglami = createContext<{
govdeBagla: (el: HTMLDivElement | null) => void;
govdeTekerlek: (e: WheelEvent<HTMLDivElement>) => void;
govdeOdak: (e: FocusEvent<HTMLDivElement>) => 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 (
<DialogPrimitive.Root open={acik} onOpenChange={setAcik}>
<KokBaglami.Provider value={baglam}>{children}</KokBaglami.Provider>
</DialogPrimitive.Root>
);
}
function AltSayfaTetik(props: ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="alt-sayfa-tetik" {...props} />;
}
function AltSayfaKapat(props: ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="alt-sayfa-kapat" {...props} />;
}
type IcerikProps = Omit<ComponentProps<typeof DialogPrimitive.Content>, "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.
<AnimatePresence>
{kok.acik ? (
<DialogPrimitive.Portal key="alt-sayfa" forceMount>
<Sayfa {...props} kapat={kok.kapat} />
</DialogPrimitive.Portal>
) : null}
</AnimatePresence>
);
}
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<HTMLDivElement>(null);
const govdeRef = useRef<HTMLDivElement | null>(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>(durak);
const surukleme = useRef<Surukleme | null>(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<HTMLDivElement>) => {
if (!tamAcik() && e.deltaY > 4 && !surukleme.current) dinlen(ust);
},
govdeOdak: (e: FocusEvent<HTMLDivElement>) => {
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<HTMLButtonElement>) {
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 (
<>
<DialogPrimitive.Overlay asChild forceMount>
<motion.div
data-slot="alt-sayfa-perde"
// Ayrılırken altındaki hiçbir şey engellenmez: tetiğe basış kapanışı geri alabilir.
className="fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-[state=closed]:pointer-events-none!"
style={{ opacity: perde }}
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content
asChild
forceMount
onOpenAutoFocus={(e) => {
// 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}
>
<motion.div
ref={sayfaRef}
data-slot="alt-sayfa"
className={cn(
// `after`: kenarın altına uzanan yüzey — yukarı esnemede sayfanın altında boşluk görünmez.
"group/alt-sayfa fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col rounded-t-3xl border border-b-0 border-slate-200 bg-card text-slate-900 shadow-2xl outline-none will-change-transform after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-40 after:bg-card data-[state=closed]:pointer-events-none! sm:max-w-2xl",
className,
)}
style={{ y, opacity: varlik, maxHeight: `${kesirler[ust] * 100}dvh` }}
>
<div
className="flex h-6 shrink-0 cursor-grab touch-none items-end justify-center select-none group-data-[surukleniyor]/alt-sayfa:cursor-grabbing"
{...baglam.tutamak}
>
{cokDurakli ? (
<button
type="button"
data-tutamac=""
aria-expanded={genis}
onClick={tutamacTik}
onKeyDown={tutamacTus}
className="flex h-6 w-16 cursor-[inherit] items-end justify-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="h-1.5 w-12 rounded-full bg-slate-200 transition-colors duration-150 group-data-[surukleniyor]/alt-sayfa:bg-slate-400" aria-hidden />
<span className="sr-only">{genis ? "Sayfayı küçült" : "Sayfayı genişlet"}</span>
</button>
) : (
<span className="h-1.5 w-12 rounded-full bg-slate-200 transition-colors duration-150 group-data-[surukleniyor]/alt-sayfa:bg-slate-400" aria-hidden />
)}
</div>
<SayfaBaglami.Provider value={baglam}>{children}</SayfaBaglami.Provider>
<span className="sr-only" role="status" aria-live="polite">
{duyuru}
</span>
</motion.div>
</DialogPrimitive.Content>
</>
);
}
/**
* 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 (
<div
ref={sayfa?.govdeBagla}
data-slot="alt-sayfa-govde"
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain group-data-[kisik]/alt-sayfa:touch-none group-data-[kisik]/alt-sayfa:overflow-hidden",
className,
)}
onWheel={sayfa?.govdeTekerlek}
onFocus={sayfa?.govdeOdak}
{...props}
/>
);
}
/** 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 (
<div
data-slot="alt-sayfa-surukle"
className={cn("touch-none select-none", className)}
{...sayfa?.tutamak}
{...props}
/>
);
}
function AltSayfaBaslik({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="alt-sayfa-baslik"
className={cn("font-heading text-2xl font-bold text-slate-900", className)}
{...props}
/>
);
}
function AltSayfaAciklama({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="alt-sayfa-aciklama"
className={cn("text-sm text-slate-500", className)}
{...props}
/>
);
}
export {
AltSayfa,
AltSayfaTetik,
AltSayfaKapat,
AltSayfaIcerik,
AltSayfaGovde,
AltSayfaSurukle,
AltSayfaBaslik,
AltSayfaAciklama,
};

View File

@@ -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<Tur, string> = {
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<string, number>, acik: boolean) {
const on = liste.length ? (boylar[liste[0].id] ?? 0) : 0;
const ustler = liste.reduce<number[]>(
(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 <motion.span {...props} aria-hidden={burada ? props["aria-hidden"] : true} />;
}
function TurIkonu({ tur }: { tur: Tur }) {
if (tur === "basari") return <CircleCheck className="size-5 text-emerald-600" aria-hidden />;
if (tur === "bilgi") return <Info className="size-5 text-primary" aria-hidden />;
if (tur === "uyari") return <TriangleAlert className="size-5 text-amber-600" aria-hidden />;
if (tur === "hata") return <OctagonX className="size-5 text-red-600" aria-hidden />;
// Sürüyor: sitenin ortak "bekle" ikonu (§4.20); tür değişince dönen halka durur.
return (
<Loader2 className="size-5 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
);
}
// 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<HTMLLIElement>(null);
const icerikRef = useRef<HTMLDivElement>(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<boolean | null>(null);
const kaydirildi = useRef(false);
const kalan = useRef(kayit.sure);
const hareket = useRef<Hareket | null>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>, 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<HTMLDivElement>) {
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<HTMLLIElement>) {
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 (
<motion.li
ref={ogeRef}
// Saklanan bildirim alt kenarından küçülür: öndekinin altından tam pay kadar görünür.
className="absolute inset-x-0 top-0 origin-bottom"
style={{ y, scale: olcek, height: boy, opacity: opaklik, zIndex: kayit.sira }}
inert={gizli}
onKeyDown={tusaBasildi}
>
<motion.div
className={cn(
"absolute inset-0 touch-pan-y overflow-hidden rounded-2xl border border-slate-200 shadow-lg shadow-slate-900/5 transition-colors duration-200",
onde || acik ? "bg-card" : "bg-slate-50",
)}
style={{ x, opacity: kaydirmaSonmesi }}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={(e) => birakildi(e, false)}
onPointerCancel={(e) => birakildi(e, true)}
onClick={kartaTiklandi}
>
<motion.div
ref={icerikRef}
// Üst-alt 6 px + 36 px'lik ilk satır şeridi: tek satırlık bildirim 48 px.
className="flex items-start gap-3 py-1.5 pr-1.5 pl-4"
style={{ opacity: icerikOpakligi }}
>
{kayit.tur === "duz" ? null : (
<span className="relative grid h-9 w-5 shrink-0 place-items-center" aria-hidden>
<AnimatePresence mode="popLayout" initial={false}>
<Degisen
key={kayit.tur}
className="col-start-1 row-start-1 grid place-items-center"
{...(azalt ? yalnizOpaklik : ikonDegisimi)}
>
<TurIkonu tur={kayit.tur} />
</Degisen>
</AnimatePresence>
</span>
)}
<div className="relative min-w-0 flex-1 py-2 text-sm leading-5">
<span className="sr-only">{TUR_ETIKETI[kayit.tur]}: </span>
<span className="relative block font-semibold text-slate-900">
<AnimatePresence mode="popLayout" initial={false}>
<Degisen
key={typeof kayit.baslik === "string" ? kayit.baslik : kayit.surum}
className="block"
{...degisim}
>
{kayit.baslik}
</Degisen>
</AnimatePresence>
</span>
{kayit.aciklama ? (
<span className="mt-0.5 block text-pretty text-slate-600">{kayit.aciklama}</span>
) : null}
{kayit.aksiyon && aksiyonAltta ? (
<button
type="button"
onClick={() => aksiyonuCalistir(id)}
className={cn(AKSIYON_HAPI, "mt-2.5")}
>
{kayit.aksiyon.label}
</button>
) : null}
</div>
{/* Sağ grup: aksiyon ile çarpı yan yana, ilk satırla aynı hizada */}
<div className="flex shrink-0 items-center gap-1">
{kayit.aksiyon && !aksiyonAltta ? (
<button
type="button"
onClick={() => aksiyonuCalistir(id)}
className={AKSIYON_HAPI}
>
{kayit.aksiyon.label}
</button>
) : null}
<button
type="button"
onClick={() => 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]"
>
<X className="size-4" aria-hidden />
<span className="sr-only">Bildirimi kapat</span>
</button>
</div>
</motion.div>
</motion.div>
</motion.li>
);
}
// ── 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<Record<string, number>>({});
const bolgeRef = useRef<HTMLElement>(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 (
<section
ref={bolgeRef}
// Alt sayfa (alt-sayfa.tsx) bu işaretin içindeki tıklamayı "dışarı
// tıklama" saymaz: "Geri al"a basmak çekmeceyi kapatmaz.
data-bildirim-yigini=""
aria-label="Bildirimler"
aria-live="polite"
aria-relevant="additions text"
aria-atomic="false"
className="pointer-events-none fixed inset-x-0 top-4 z-[70] mx-auto w-[min(23rem,calc(100%-2rem))] sm:top-6 print:hidden"
onFocus={(e) => {
// 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);
}
}}
>
<ol
className="pointer-events-auto relative"
style={{ height: listeBoyu }}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") setUzerinde(true);
}}
onPointerLeave={(e) => {
if (e.pointerType === "mouse") setUzerinde(false);
}}
>
<AnimatePresence onExitComplete={buda}>
{liste.map((kayit, i) => (
<Bildirim
key={kayit.id}
kayit={kayit}
hedef={hedefler[i]}
acik={acik}
onde={i === 0}
gizli={i >= GORUNEN}
durdu={durdu}
azalt={azalt}
olcumBildir={olcumBildir}
surukleniyor={setSurukleme}
dokunuldu={dokunuldu}
/>
))}
</AnimatePresence>
</ol>
</section>
);
}

View File

@@ -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<AnimationPlaybackControls | null>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>, 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<HTMLDivElement>) {
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 (
<div
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-controls={kontrol}
aria-label={`${etiket} genişliği`}
aria-valuenow={deger?.simdi}
aria-valuemin={deger?.enAz}
aria-valuemax={deger?.enCok}
aria-valuetext={deger ? `${deger.simdi} piksel` : undefined}
title="Sürükleyerek genişliği değiştir · çift tık: varsayılan"
data-etkin={etkin || undefined}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={(e) => 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,
)}
>
<span
aria-hidden
className="absolute inset-y-2 left-1/2 w-px -translate-x-1/2 rounded-full bg-orange-500 opacity-0 transition-opacity duration-150 group-hover/bolme:opacity-40 group-focus-visible/bolme:opacity-40 group-data-[etkin]/bolme:opacity-70"
/>
<span
aria-hidden
className="absolute top-1/2 left-1/2 h-10 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-300 transition-[background-color,height] duration-200 group-hover/bolme:h-14 group-hover/bolme:bg-orange-500 group-focus-visible/bolme:h-14 group-focus-visible/bolme:bg-orange-500 group-data-[etkin]/bolme:h-14 group-data-[etkin]/bolme:bg-orange-500 motion-reduce:transition-none"
/>
</div>
);
}

View File

@@ -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 (
<button
type="button"
data-cip=""
onClick={onClick}
aria-pressed={secili}
className={cn(
"inline-flex cursor-pointer items-center rounded-full border text-sm font-semibold outline-none transition-[background-color,border-color,color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
kucuk ? "h-10 px-3.5" : "h-11 px-4",
secili
? "border-primary bg-primary text-white"
: 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",
className,
)}
>
{/* 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. */}
<motion.span
aria-hidden
className="flex h-3.5 shrink-0 items-center overflow-hidden"
initial={false}
animate={{ width: secili ? YUVA : 0 }}
transition={gecis}
>
<svg
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<motion.path
d="M4 12.5 9.5 18 20 6.5"
initial={false}
animate={{ pathLength: secili ? 1 : 0, opacity: secili ? 1 : 0 }}
transition={gecis}
/>
</svg>
</motion.span>
{children}
</button>
);
}
/**
* Ç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<ComponentProps<"div">, "aria-label" | "role"> & {
/** Grubun erişilebilir adı, ör. "İlgi alanları". */
etiket: string;
}) {
function tus(e: KeyboardEvent<HTMLDivElement>) {
const cipler = Array.from(
e.currentTarget.querySelectorAll<HTMLButtonElement>("button[data-cip]"),
);
const konum = cipler.indexOf(document.activeElement as HTMLButtonElement);
if (konum < 0) return;
const son = cipler.length - 1;
const hedefler: Record<string, number> = {
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 (
<div
role="group"
aria-label={etiket}
className={cn("flex flex-wrap gap-2", className)}
onKeyDown={tus}
{...props}
>
{children}
</div>
);
}

View File

@@ -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<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerHandle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Handle>) {
return (
<DrawerPrimitive.Handle
data-slot="drawer-handle"
className={cn(
"mx-auto mt-4 hidden h-1.5 w-25 shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
showSwipeHandle = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
showSwipeHandle?: boolean
}) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
{showSwipeHandle ? <DrawerHandle /> : null}
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -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<number>;
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<HTMLDivElement>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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 (
<motion.div
className="pointer-events-none absolute inset-x-0 top-0 h-(--kart-boyu) origin-bottom"
style={{ zIndex, scale: olcek, y: inis, opacity: varlik }}
aria-hidden={etkin ? undefined : true}
inert={!etkin}
>
<motion.div
ref={kartRef}
// Duran kart kenarlığında düz durur; eldeki ya da uçan kart gölge alır.
className={cn(
"group/kart absolute inset-0 touch-pan-y rounded-3xl select-none before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:opacity-0 before:shadow-lg before:shadow-slate-900/10 before:transition-opacity before:duration-150 data-[tutuluyor]:cursor-grabbing data-[tutuluyor]:before:opacity-100",
etkin ? "pointer-events-auto cursor-grab" : "pointer-events-none",
ucuyor && "before:opacity-100",
)}
style={{ x, y, rotate: donme, opacity: opaklik }}
role={etkin ? "group" : undefined}
aria-label={etkin ? ad : undefined}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={birakildi}
onPointerCancel={birakildi}
>
<div className="relative h-full overflow-hidden rounded-[inherit] border border-slate-200 bg-card text-slate-900">
{children}
<motion.span
className="pointer-events-none absolute inset-0 bg-slate-900/5"
style={{ opacity: golge }}
aria-hidden
/>
</div>
{etkin || ucuyor ? (
<>
<motion.span
className="pointer-events-none absolute top-4 left-4 inline-flex h-9 items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 pr-3.5 pl-2.5 text-sm font-semibold whitespace-nowrap text-primary"
style={{ opacity: sagDamga }}
aria-hidden
>
<Check className="size-4" />
{etiketler.sag}
</motion.span>
<motion.span
className="pointer-events-none absolute top-4 right-4 inline-flex h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 pr-3.5 pl-2.5 text-sm font-semibold whitespace-nowrap text-slate-700"
style={{ opacity: solDamga }}
aria-hidden
>
<X className="size-4" />
{etiketler.sol}
</motion.span>
</>
) : null}
</motion.div>
</motion.div>
);
}
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 (
<button
type="button"
className={cn(DUGME, DUGME_TON[ton])}
aria-disabled={pasif || undefined}
onClick={() => {
if (!pasif) onClick();
}}
>
{children}
</button>
);
}
/** Ö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<T>({
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<string[]>([]);
const [donenler, setDonenler] = useState<Record<string, Donus>>({});
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<Record<string, Cikis>>({});
const fiskeler = useRef(new Map<string, Nokta>());
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<string, Donus> = {};
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<HTMLDivElement>) {
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 (
<div
className={cn(
"grid w-full justify-items-center gap-5 [--kart-boyu:19rem]",
className,
)}
>
<div
className="group/sahne relative isolate h-[calc(var(--kart-boyu)+24px)] w-full max-w-80 rounded-3xl outline-none"
role="group"
aria-roledescription="kart destesi"
aria-label={etiket}
aria-describedby={ipucuId}
tabIndex={0}
onKeyDown={tus}
>
{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 (
<DesteKarti
key={id}
id={id}
derinlik={derinlik}
karar={karar}
donus={donenler[id]}
etkin={!karar && derinlik === 0}
zIndex={karar ? 20 + ucanlar.indexOf(id) : GORUNEN - derinlik}
ad={`${ad(oge)}, ${sira + 1} / ${ogeler.length}`}
etiketler={etiketler}
kalkis={kalkis}
azalt={azalt}
firlatmaHizi={(k) => {
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)}
</DesteKarti>
);
})}
<AnimatePresence initial={false}>
{bitti ? (
<motion.div
key="bos"
className="absolute inset-x-0 top-0 grid h-(--kart-boyu) place-content-center justify-items-center gap-3 rounded-3xl border border-slate-200 bg-card p-6 text-center"
initial={azalt ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: SURE.anlik } }}
transition={azalt ? SADE_SOLMA : { duration: SURE.standart, ease: EGRI.giris }}
>
{bos ? (
<BosYuva ciz={bos} onSifirla={sifirla} />
) : (
<>
<p className="font-heading text-lg font-bold text-slate-900">Kartlar bitti</p>
<button
type="button"
className={cn(DUGME, DUGME_TON.ikincil)}
onClick={sifirla}
>
<RotateCcw className="size-4" aria-hidden />
Baştan başla
</button>
</>
)}
</motion.div>
) : null}
</AnimatePresence>
{/* Odak halkası sahnenin değil üst kartın çevresinde görünür */}
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 hidden h-(--kart-boyu) rounded-3xl ring-3 ring-ring/50 group-focus-visible/sahne:block"
/>
</div>
<div className="flex flex-nowrap justify-center gap-2">
<Dugme ton="ikincil" pasif={!ust} onClick={() => kararVer("sol")}>
<X className="size-4" aria-hidden />
{etiketler.sol}
</Dugme>
<Dugme ton="sessiz" pasif={!gecmis.length} onClick={geriAl}>
<RotateCcw className="size-4" aria-hidden />
Geri al
</Dugme>
<Dugme ton="onay" pasif={!ust} onClick={() => kararVer("sag")}>
<Check className="size-4" aria-hidden />
{etiketler.sag}
</Dugme>
</div>
<p id={ipucuId} className="sr-only">
Üstteki kartı sürükle ya da sol ve sağ ok tuşlarıyla karar ver. Geri tuşu son kararı
geri alır.
</p>
<p className="sr-only" role="status" aria-live="polite">
{duyuru}
</p>
</div>
);
}

View File

@@ -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<SetStateAction<string | null>>;
satirlar: Map<string, HTMLElement>;
};
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<NonNullable<KaydirmaEylemi["ton"]>, 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<Grup>({
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<string | null>(null);
const [satirlar] = useState(() => new Map<string, HTMLElement>());
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 <GrupBaglami.Provider value={grup}>{children}</GrupBaglami.Provider>;
}
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<HTMLDivElement>(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<Surukleme | null>(null);
const yolculuk = useRef<AnimationPlaybackControls | null>(null);
const gerilme = useRef<AnimationPlaybackControls | null>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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 (
<div ref={satirRef} className={cn("relative isolate overflow-hidden", className)}>
{(["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 (
<EylemKatmani
key={`${yan}-${sira}`}
eylem={eylem}
yan={yan}
kat={kat}
adet={adet}
kaplayanKat={kaplayanKat}
x={x}
kaplama={kaplama}
onBas={() => uygula(yan, sira)}
/>
);
}),
)}
<motion.div
// Dikey kaydırma sayfada kalır; içerik parmağı yalnız transform'la izler.
className={cn("relative z-10 touch-pan-y select-none", icerikSinifi)}
style={{ x }}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={bitti}
onPointerCancel={bitti}
onClickCapture={tiklamaYakala}
onDragStart={(e) => e.preventDefault()}
>
{children}
</motion.div>
</div>
);
}
/**
* 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<number>;
kaplama: MotionValue<number>;
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 (
<motion.button
type="button"
// Erişilebilir yol satırdaki görünür düğmedir; bu kopya sekme sırasına ve
// erişilebilirlik ağacına girmez.
tabIndex={-1}
aria-hidden
className={cn(
"absolute inset-y-0 w-full cursor-pointer outline-none",
yan === "sag" ? "left-full" : "right-full",
TON[eylem.ton ?? "notr"],
)}
style={{ x: kayma, zIndex: kat + 1 }}
onClick={onBas}
>
<motion.span
className={cn("absolute inset-y-0 w-0", yan === "sag" ? "left-0" : "right-0")}
style={{ x: isaretX }}
>
<span
className="absolute inset-y-0 flex flex-col items-center justify-center gap-0.5"
style={{ left: -EN / 2, width: EN }}
>
<motion.span
className="grid place-items-center [&_svg]:size-5"
style={{ opacity: ikonGorunum, scale: ikonOlcek }}
>
{eylem.ikon}
</motion.span>
<motion.span
className="text-xs font-semibold whitespace-nowrap"
style={{ opacity: etiketGorunum }}
>
{eylem.etiket}
</motion.span>
</span>
</motion.span>
</motion.button>
);
}

View File

@@ -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 (
<Sonner
theme={resolvedTheme === "dark" ? "dark" : "light"}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<span className="block size-2.5 animate-pulse rounded-full bg-current" />
),
}}
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 }

View File

@@ -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<string, number>();
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<HTMLSpanElement>(null);
const serit = useRef<HTMLSpanElement>(null);
const genislik = useRef(0);
const boyutlama = useRef<AnimationPlaybackControls | null>(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 (
<span className={className}>
<span className="sr-only">{children}</span>
<span
ref={cerceve}
aria-hidden
// Dolgu + eksi kenar boşluğu: maskeye bulanıklık ve kuyruklu harfler
// için yer açar, yerleşimi değiştirmez. Şerit hep çerçevenin başından başlar.
className="relative -my-[0.5em] -mr-[0.5em] -ml-[0.2em] box-content inline-block py-[0.5em] pr-[0.5em] pl-[0.2em] text-start whitespace-nowrap [mask-image:linear-gradient(90deg,#000_calc(100%-0.5em),transparent)]"
>
<span ref={serit} className="relative inline-block whitespace-nowrap">
<AnimatePresence mode="popLayout" initial={false}>
{harfler.map(({ harf, anahtar }) => {
const sira = kalanlar.has(anahtar) ? 0 : giren++;
return (
<motion.span
key={anahtar}
layout="position"
className="inline-block origin-[50%_60%] whitespace-pre"
initial={
azalt ? false : { opacity: 0, scale: 0.8, y: "0.14em", filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, y: "0em", filter: "blur(0px)" }}
exit={
azalt
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
scale: 0.86,
y: "-0.1em",
filter: "blur(4px)",
transition: { duration: SURE.cikis, ease: EGRI.standart },
}
}
transition={
azalt
? { duration: 0 }
: {
layout: YAY.donusum,
default: {
duration: SURE.standart + 0.04,
ease: EGRI.giris,
delay: DEVIR + Math.min(sira, 8) * HARF_ARALIGI,
},
}
}
>
{harf === " " ? " " : harf}
</motion.span>
);
})}
</AnimatePresence>
</span>
</span>
</span>
);
}

View File

@@ -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 (
<Drawer handleOnly>
<DrawerTrigger asChild>
<AltSayfa>
<AltSayfaTetik asChild>
<button
type="button"
className="mt-4 flex w-full cursor-pointer items-center gap-4 group rounded-2xl border border-slate-200 bg-card p-4 text-left transition-[background-color,border-color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
@@ -62,23 +63,23 @@ export function AkademikAyrinti({
aria-hidden
/>
</button>
</DrawerTrigger>
<DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl">
<DrawerClose className="absolute top-3 right-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]">
</AltSayfaTetik>
<AltSayfaIcerik>
<AltSayfaKapat className="absolute top-3 right-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]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
</AltSayfaKapat>
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{baslik}
</DrawerTitle>
<DrawerDescription className="mt-1 px-10 text-center text-sm text-slate-500">
</AltSayfaBaslik>
<AltSayfaAciklama className="mt-1 px-10 text-center text-sm text-slate-500">
{baglam}
</DrawerDescription>
</AltSayfaAciklama>
<AcilisSahnesi>{children}</AcilisSahnesi>
</div>
</DrawerContent>
</Drawer>
</AltSayfaGovde>
</AltSayfaIcerik>
</AltSayfa>
);
}

View File

@@ -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 ? (
<div data-kare="sol" className={`${kare} min-h-40 bg-orange-500 py-4`}>
<dt className={etiket}>Kayıtlı çalışan</dt>
<dt className={etiket}>İşe yerleşen</dt>
<dd className={`mt-3 ${devSayi}`}>%{ondalik(veri.istihdam)}</dd>
{ort.istihdam != null ? (
<dd className="mt-2 text-base font-bold text-white/90">
@@ -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.
</p>
@@ -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) => (
<KartBaglantisi
@@ -554,13 +550,14 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
</div>
{/* 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 ? (
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-3 [&>*:last-child]:col-span-2 lg:[&>*:last-child]:col-span-1">
<OzetKart
ikon={Target}
etiket="En seçici"
etiket="En yüksek taban"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
@@ -575,11 +572,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
)
}
/>
{/* 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. */}
<OzetKart
ikon={Landmark}
etiket="En geniş kapı"
etiket="En düşük taban"
{...(taban.enGenis
? kartSayisi(taban.enGenis.sira)
: { deger: "—" })}
@@ -595,18 +592,9 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
)
}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban].reverse().find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
})()}
alt={ortaTabanAciklamasi(taban)}
/>
<OzetKart
ikon={Armchair}
etiket="Boş koltuk"
etiket="Boş kontenjan"
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
alt={
taban.doluluk

View File

@@ -37,13 +37,14 @@ import { UNITUR_ETIKET } from "@/lib/unitur";
import { cn } from "@/lib/utils";
import { SON_YIL } from "@/lib/veri-yillari";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
AltSayfa,
AltSayfaKapat,
AltSayfaGovde,
AltSayfaIcerik,
AltSayfaAciklama,
AltSayfaBaslik,
AltSayfaTetik,
} from "@/components/ui/alt-sayfa";
import {
Table,
TableBody,
@@ -137,8 +138,8 @@ export function ProgramAyrintiButonu({
}
return (
<Drawer onOpenChange={acildi} handleOnly>
<DrawerTrigger asChild>
<AltSayfa onOpenChange={acildi}>
<AltSayfaTetik asChild>
{varyant === "metin" ? (
<button
type="button"
@@ -160,21 +161,24 @@ export function ProgramAyrintiButonu({
<span className="sr-only">{isim} — program ayrıntısı</span>
</button>
)}
</DrawerTrigger>
{/* Çekmece kabı ve çarpı cta-sira-form.tsx'teki çekmeceyle birebir (§4.10, §4.3) */}
<DrawerContent
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
</AltSayfaTetik>
{/* 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). */}
<AltSayfaIcerik
duraklar={[0.6, 0.92]}
ilkDurak={0}
onClick={(e) => e.stopPropagation()}
>
<DrawerClose className="absolute top-3 right-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]">
<AltSayfaKapat className="absolute top-3 right-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]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
</AltSayfaKapat>
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{isim}
</DrawerTitle>
<DrawerDescription className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
</AltSayfaBaslik>
<AltSayfaAciklama className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
{veri
? [
trBaslikDuzeni(veri.program.universite),
@@ -184,13 +188,13 @@ export function ProgramAyrintiButonu({
.filter(Boolean)
.join(" · ")
: (universite ?? "Programın tüm açık verisi")}
</DrawerDescription>
</AltSayfaAciklama>
<div className="mt-5">
<AyrintiIcerik durum={durum} veri={veri} />
</div>
</div>
</DrawerContent>
</Drawer>
</AltSayfaGovde>
</AltSayfaIcerik>
</AltSayfa>
);
}

View File

@@ -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,

View File

@@ -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. */}
<dl className="contents">
<OzetKart
ikon={Target}
etiket="En seçici bölüm"
etiket="En yüksek taban"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
@@ -708,15 +708,28 @@ export function UniversiteIcerik({
}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban]
.reverse()
.find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
ikon={Landmark}
etiket="En düşük taban"
{...(taban.enGenis
? kartSayisi(taban.enGenis.sira)
: { deger: "—" })}
alt={
taban.enGenis ? (
<>
<KartBaglantisi
href={(() => {
const s = bolumSlugFromIsim(taban.enGenis.isim);
return s ? `/bolum/${s}` : null;
})()}
alt={ortaTabanAciklamasi(taban)}
>
{bolumBazAdi(taban.enGenis.isim)}
</KartBaglantisi>{" "}
· {SON_YIL} taban sırası
</>
) : (
"henüz taban oluşmadı"
)
}
/>
{taban.doluluk ? (
<OzetKart
@@ -725,7 +738,7 @@ export function UniversiteIcerik({
deger={`%${taban.doluluk.yuzde}`}
alt={
taban.doluluk.bosKoltuk > 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}`
}
/>

View File

@@ -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";

View File

@@ -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 (
<Drawer
<AltSayfa
open={acik}
onOpenChange={(v) => {
if (v) cekmeceAc();
@@ -153,16 +152,11 @@ export function ListeCekmecesi() {
cekmeceKapat();
}
}}
handleOnly
onAnimationEnd={(open) => setOturdu(open)}
>
<DrawerContent
className="min-h-0 max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
<AltSayfaIcerik onOturdu={setOturdu} aria-describedby={undefined}>
{/* 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. */}
<DrawerHeader className="relative border-b border-slate-100 px-4 pt-5 pb-4 text-center md:text-center">
solda. Başlık alanından da tutulup aşağı çekilir. */}
{liste.length > 0 ? (
<button
type="button"
@@ -179,13 +173,14 @@ export function ListeCekmecesi() {
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<DrawerClose className="absolute top-3 right-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]">
<AltSayfaKapat className="absolute top-3 right-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]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
</AltSayfaKapat>
<AltSayfaSurukle className="border-b border-slate-100 px-4 pt-3 pb-4 text-center">
<AltSayfaBaslik className="px-10 text-center">
Kendi Listem
</DrawerTitle>
</AltSayfaBaslik>
{/* 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.
</p>
) : null}
</DrawerHeader>
</AltSayfaSurukle>
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-2"
>
<AltSayfaGovde className="overflow-x-hidden px-4 py-2">
{liste.length === 0 ? (
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
<ListChecks
@@ -258,6 +251,7 @@ export function ListeCekmecesi() {
</p>
</div>
) : (
<KaydirmaGrubu>
<Reorder.Group
axis="y"
values={liste}
@@ -276,6 +270,7 @@ export function ListeCekmecesi() {
))}
</AnimatePresence>
</Reorder.Group>
</KaydirmaGrubu>
)}
{liste.length < MANUEL_LISTE_MAX ? (
@@ -290,9 +285,9 @@ export function ListeCekmecesi() {
Program ekle
</button>
) : null}
</div>
</AltSayfaGovde>
<DrawerFooter className="border-t border-slate-100 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
<div className="mt-auto flex flex-col gap-2 border-t border-slate-100 px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
{/* 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() {
</Button>
)}
</CtaBandi>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
</AltSayfaIcerik>
</AltSayfa>
);
}
/** 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)
) {
const azalt = useReducedMotion();
const cikarIste = () =>
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
}
};
return (
<Reorder.Item
@@ -414,36 +396,34 @@ function CekmeceSatiri({
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
dragListener={false}
dragControls={siraKontrol}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
initial={azalt ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
// Satır "Çıkar" bandının altında yana kayarak gider (KaydirmaSatiri);
// burada yalnız söner, alttakiler layout animasyonuyla yukarı gelir.
exit={{ opacity: 0, transition: { duration: 0.16, delay: azalt ? 0 : 0.14 } }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{ scale: 1.03, zIndex: 10 }}
className="relative select-none border-b border-slate-100 last:border-b-0"
className="relative border-b border-slate-100 last:border-b-0"
>
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
<motion.div
style={{ opacity: silGorunur }}
className="absolute inset-0 flex items-center bg-red-500 pl-4"
aria-hidden
>
<Trash2 className="size-4 text-white" />
</motion.div>
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
<motion.div
drag="x"
style={{ x }}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0, right: 0.7 }}
dragSnapToOrigin
onDragEnd={kaydirinca}
className="relative flex items-center gap-3 bg-card py-3"
{/* Sola kaydır: "Çıkar" açılır; sonuna kadar çekince uygulanır (§4.23) */}
<KaydirmaSatiri
sag={[
{
etiket: "Çıkar",
ikon: <CircleMinus aria-hidden />,
ton: "tehlike",
onSec: cikarIste,
},
]}
icerikSinifi="flex items-center gap-3 bg-card py-3"
>
<button
type="button"
// Dikey sürükleme sıralamaya aittir: ne yatay kaydırmayı ne de alt
// sayfanın aşağı çekilişini başlatır.
data-alt-sayfa-yoksay=""
onPointerDown={(e) => {
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({
<Link
href={bolumHref(tercih.isim)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className={SATIR_AD}
>
{tercih.isim}
@@ -468,7 +447,6 @@ function CekmeceSatiri({
<Link
href={uniHref(tercih.universite)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
title={`${tercih.universite} taban puanları sayfası`}
className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
>
@@ -491,18 +469,16 @@ function CekmeceSatiri({
</span>
<button
type="button"
onClick={() =>
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]"
>
<CircleMinus className="size-5" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span>
</button>
</motion.div>
</KaydirmaSatiri>
</Reorder.Item>
);
}

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 (
<div className="flex h-full flex-col">
{/* Künye şeridi: sürüklerken karar damgaları bu şeridin üstünde belirir */}
<div className="flex h-16 shrink-0 items-center justify-end gap-3 border-b border-slate-100 bg-slate-50 px-5 text-sm font-semibold text-slate-500">
{program.il ? (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-4 text-primary" aria-hidden />
{program.il}
</span>
) : null}
<span className="inline-flex items-center gap-1.5">
<Landmark className="size-4 text-primary" aria-hidden />
Devlet
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col p-5 text-left">
<p className="line-clamp-2 font-heading text-xl font-bold leading-snug text-slate-900">
{program.isim}
</p>
<p className="mt-1 line-clamp-1 text-sm text-slate-500">{program.universite}</p>
<div className="mt-auto">
<p className="text-sm font-semibold text-slate-500">Son taban sıralaması</p>
<p className="mt-1 font-heading text-4xl font-bold leading-none tracking-tight text-slate-900">
{program.tabanSira.toLocaleString("tr-TR")}
</p>
<p className="mt-3 flex items-center gap-2 text-sm text-slate-500">
<RiskEtiketi risk={program.risk} />
<span aria-hidden>·</span>
senin sıran {ornekSira}
</p>
</div>
</div>
</div>
);
}
export function DesteDemosu({
programlar,
ornekSira,
}: {
programlar: OrnekProgram[];
/** Biçimlenmiş örnek sıralama ("85.000"). */
ornekSira: string;
}) {
const [alinanlar, setAlinanlar] = useState<string[]>([]);
return (
<KartDestesi
ogeler={programlar}
anahtar={(p) => 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) => <ProgramKarti program={p} ornekSira={ornekSira} />}
bos={(sifirla) => (
<>
<p className="font-heading text-xl font-bold text-balance text-slate-900">
{alinanlar.length > 0
? `${alinanlar.length} programı listene aldın`
: "Hiçbirini almadın, olur"}
</p>
<p className="max-w-56 text-sm leading-relaxed text-slate-600">
Gerçek listede bunu 24 tercih için biz yaparız; her birinin gerekçesini de yazarız.
</p>
<Button
asChild
size="lg"
className="mt-1 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
<Link href="#hero-form">
Sıralamamı gir
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<button
type="button"
onClick={sifirla}
className="min-h-11 cursor-pointer text-sm font-semibold text-slate-500 transition-colors duration-150 hover:text-orange-500"
>
Kartları baştan gör
</button>
</>
)}
/>
);
}

View File

@@ -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 <canvas>, 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<HTMLDivElement>(null);
const tuvalRef = useRef<HTMLCanvasElement>(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<string>();
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 (
<figure className={cn("mx-auto w-full max-w-3xl", className)}>
{/* Mini pencere: aramadaki tarayıcı penceresiyle aynı dil (§4.20) */}
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
<div className="flex h-9 items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-3.5">
<span className="size-2 rounded-full bg-slate-300" aria-hidden />
<span className="size-2 rounded-full bg-slate-300 opacity-65" aria-hidden />
<span className="size-2 rounded-full bg-slate-300 opacity-35" aria-hidden />
<span className="ml-2 text-xs font-medium text-slate-500">{etiket}</span>
</div>
<div ref={kapRef} className="h-36 sm:h-44">
<canvas
ref={tuvalRef}
role="img"
aria-label="Akan piksel kareler: her kare bir program. Aralarında 24 turuncu kare senin tercih listen."
className="block size-full text-primary"
/>
</div>
</div>
<figcaption className="mt-3 flex items-center justify-center gap-2 text-center text-sm text-slate-500">
<span className="size-2.5 shrink-0 rounded-[3px] bg-orange-500" aria-hidden />
Her kare bir program; turuncular senin 24 tercihin.
</figcaption>
</figure>
);
}

View File

@@ -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<number>;
my: MotionValue<number>;
}) {
// 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 (
<motion.div style={{ x, y }}>
<motion.div
initial={false}
animate={durum === "bekliyor" ? gizli : { opacity: 1, y: 0, rotate: 0 }}
transition={
durum !== "geldi"
? { duration: 0 }
: azalt
? { duration: 0.2, delay: sira * 0.02 }
: { ...DUSUS, delay: sira * 0.045, opacity: { duration: 0.2, delay: sira * 0.045 } }
}
>
<Link
href={hap.href}
className={cn(
"inline-flex items-center gap-2 rounded-full border border-slate-200 bg-card font-semibold text-slate-800 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]",
iri ? "h-12 px-5 text-base" : "h-11 px-4 text-sm",
)}
>
<Ikon className="size-4 shrink-0 text-primary" aria-hidden />
{hap.etiket}
<span className="sr-only">
{hap.tur === "sehir" ? " üniversiteleri" : " taban puanları"}
</span>
</Link>
</motion.div>
</motion.div>
);
}
function ArkaKare({
kare,
mx,
my,
}: {
kare: (typeof KARELER)[number];
mx: MotionValue<number>;
my: MotionValue<number>;
}) {
// 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 (
<motion.span
className={cn(
"absolute rounded",
kare.sicak ? "bg-orange-500/70" : "bg-primary/15",
)}
style={{ left: `${kare.x}%`, top: `${kare.y}%`, width: kare.boy, height: kare.boy, x, y }}
/>
);
}
export function SuzulenHaplar({
haplar,
className,
}: {
haplar: KatalogHapi[];
className?: string;
}) {
const kapRef = useRef<HTMLDivElement>(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<Durum>("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 (
<div ref={kapRef} className={cn("relative isolate px-2 py-10 sm:px-10", className)}>
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
{KARELER.map((kare) => (
<ArkaKare key={`${kare.x}-${kare.y}`} kare={kare} mx={mx} my={my} />
))}
</div>
<ul className="mx-auto flex max-w-2xl flex-wrap items-center justify-center gap-x-2.5 gap-y-3">
{haplar.map((hap, i) => (
<li key={hap.href}>
<Hap hap={hap} sira={i} durum={durum} azalt={azalt} mx={mx} my={my} />
</li>
))}
</ul>
</div>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<figure className={cn("mx-auto w-full max-w-sm", className)}>
<div
ref={kapRef}
data-oynuyor={oynuyor ? "" : undefined}
role="img"
aria-label="Listen altı şeyden kurulur: senin sıralaman, ilgi alanların ve şehir tercihin; YÖK Atlas'ın taban sıralamaları, kontenjanları ve doluluk oranları."
className="group/akis relative aspect-square w-full"
>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full" aria-hidden>
{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 (
<g key={d.etiket}>
{/* Düz çizgi: karodan merkeze; uçları karoların altında */}
<line
x1={dugum.x}
y1={dugum.y}
x2={50}
y2={50}
stroke="var(--color-slate-300)"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
<rect
x={-KARE / 2}
y={-KARE / 2}
width={KARE}
height={KARE}
rx={0.6}
className={cn(
"kt-akis-kare",
d.kaynak === "sen" ? "fill-orange-500" : "fill-primary",
)}
style={
{
"--x1": `${dis.x}px`,
"--y1": `${dis.y}px`,
"--x2": `${ic.x}px`,
"--y2": `${ic.y}px`,
// Sırayla değil, dağınık kalkış: 0 · 1,1 · 0,5 · 1,6 · 0,3 · 1,3 sn
animationDelay: `${[0, 1.1, 0.5, 1.6, 0.3, 1.3][i]}s`,
} as CSSProperties
}
/>
</g>
);
})}
</svg>
{DUGUMLER.map((d) => {
const p = nokta(d.aci, YARICAP);
const ustte = d.aci < 0 || d.aci > 180;
return (
<div
key={d.etiket}
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center"
style={{ left: `${p.x}%`, top: `${p.y}%` }}
>
{/* Beyaz, dolu karo (çizgi altından geçer); yanı ikonun rengi söyler */}
<span className="flex size-14 items-center justify-center rounded-2xl border border-slate-200 bg-card">
<d.ikon
className={cn("size-6", d.kaynak === "sen" ? "text-orange-500" : "text-primary")}
aria-hidden
/>
</span>
{/* Üstteki iki düğümde etiket karonun üstünde: altında çizgi merkeze iniyor */}
<span
className={cn(
"absolute w-20 text-center text-xs font-semibold leading-tight text-slate-700",
ustte ? "bottom-full mb-1.5" : "top-full mt-1.5",
)}
>
{d.etiket}
</span>
</div>
);
})}
<div className="absolute top-1/2 left-1/2 flex size-24 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-3xl border border-slate-200 bg-card">
{/* Site başlığındaki logonun animasyonu (rota): orada üzerine
gelince, burada ekrandayken sürekli oynar. */}
<AnimasyonluMark oynat={oynuyor} className="size-14" />
</div>
</div>
<figcaption className="mt-8 text-center text-sm leading-relaxed text-slate-500">
Solda senin seçimlerin, sağda YÖK Atlas verisi. Yapay zekâ yalnız bu
ikisinin kesiştiği programlardan seçer.
</figcaption>
</figure>
);
}

View File

@@ -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<string>();
const gorulenBolum = new Set<string>();
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 [];
}
}

View File

@@ -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 {

View File

@@ -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 = (
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
<DrawerContent
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
<DrawerClose className="absolute top-3 right-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]">
<AltSayfa open={acik} onOpenChange={acikDegisti}>
<AltSayfaIcerik>
<AltSayfaKapat className="absolute top-3 right-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]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
</AltSayfaKapat>
<div
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
>
<DrawerTitle
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik
className={
siraAdimi
? "px-10 text-center font-heading text-2xl font-bold text-slate-900"
@@ -325,8 +322,8 @@ export function CtaSiraForm({
}
>
Sıralamana yakın programlar hangileri?
</DrawerTitle>
<DrawerDescription
</AltSayfaBaslik>
<AltSayfaAciklama
className={
siraAdimi
? "mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500"
@@ -336,7 +333,7 @@ export function CtaSiraForm({
Puan türünü seç, sıralamanı yaz; hayal, dengeli ve güvenli
dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan
ayrılmadan gör. Ücretsiz, üyelik gerekmez.
</DrawerDescription>
</AltSayfaAciklama>
{siraAdimi ? (
<div className="mt-5">{siraFormu}</div>
) : facetler && sira != null ? (
@@ -351,9 +348,9 @@ export function CtaSiraForm({
onTamamla={secimleriTamamla}
/>
) : null}
</div>
</DrawerContent>
</Drawer>
</AltSayfaGovde>
</AltSayfaIcerik>
</AltSayfa>
);
if (kompakt) {

View File

@@ -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 (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<div className="flex w-full max-w-md flex-col items-center">
<Button
asChild
size="lg"
@@ -122,18 +118,6 @@ export function HeroForm() {
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-sm text-slate-600">
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
devam edersin.{" "}
<button
type="button"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
>
Farklı sıralama gir
</button>
</p>
</div>
);
}

View File

@@ -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 (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili ? `${temel} border-primary bg-primary text-white` : `${temel} ${pasif}`
}
>
{children}
</button>
);
}
// 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({
<p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<CipGrubu etiket="İlgi alanları" className="mt-4">
{facetler.kategoriler.map((k) => (
<Cip
key={k.ad}
@@ -348,7 +309,7 @@ export function SihirbazAdimlar({
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</CipGrubu>
</div>
<div ref={panelRef}>
<h3 className="font-heading text-xl font-bold text-slate-900">
@@ -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. */}
<div
className={`mt-3 flex max-h-64 flex-wrap gap-2 overflow-y-auto sm:max-h-none sm:overflow-visible ${
<CipGrubu
etiket="Şehirler"
className={`mt-3 max-h-64 overflow-y-auto sm:max-h-none sm:overflow-visible ${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -424,7 +386,7 @@ export function SihirbazAdimlar({
Daha az göster
</button>
) : null}
</div>
</CipGrubu>
{ilAramaNorm &&
gorunenTurkiyeIlleri.length === 0 &&
@@ -439,8 +401,9 @@ export function SihirbazAdimlar({
<p className="mb-2 text-sm font-semibold text-slate-700">
KKTC & Yurt Dışı
</p>
<div
className={`flex flex-wrap gap-2 ${
<CipGrubu
etiket="KKTC ve yurt dışı"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -455,7 +418,7 @@ export function SihirbazAdimlar({
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
</Cip>
))}
</div>
</CipGrubu>
</div>
) : null}
@@ -500,8 +463,9 @@ export function SihirbazAdimlar({
<p className="mb-2 text-sm font-semibold text-slate-700">
Üniversite tipi
</p>
<div
className={`flex flex-wrap gap-2 ${
<CipGrubu
etiket="Üniversite tipi"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -525,7 +489,7 @@ export function SihirbazAdimlar({
>
Fark etmez
</Cip>
</div>
</CipGrubu>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-slate-700">
@@ -533,7 +497,7 @@ export function SihirbazAdimlar({
</p>
{/* 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. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<CipGrubu etiket="Senin için önemli olan" className="grid grid-cols-2 sm:flex sm:flex-wrap">
{ONCELIKLER.map((o) => (
<Cip
key={o}
@@ -544,7 +508,7 @@ export function SihirbazAdimlar({
{o}
</Cip>
))}
</div>
</CipGrubu>
<p className="mt-2 text-sm text-slate-500">
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{" "}
<span className="font-normal text-slate-500">(isteğe bağlı)</span>
</p>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<CipGrubu etiket="Özel kontenjan durumun" className="grid grid-cols-2 sm:flex sm:flex-wrap">
{OZEL_DURUMLAR.map((d) => (
<Cip
key={d}
@@ -577,7 +541,7 @@ export function SihirbazAdimlar({
{OZEL_DURUM_ETIKET[d]}
</Cip>
))}
</div>
</CipGrubu>
<p className="mt-2 text-sm text-slate-500">
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
kontenjanı olan programlar listende öne çıkar ve danışman bunu

48
src/lib/hareket.ts Normal file
View File

@@ -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);
}