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 | | 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 | | 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 | | 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. 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", ad: "modal-rozet",
seviye: "hata", seviye: "hata",
mesaj: "Çekmece/modal içinde SectionEyebrow yok; başlık + tek cümle bağlam (DESIGN.md §4.10)", 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", 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. /* Veri akışı (veri-akisi.tsx, DESIGN.md §4.28): kare, çizgi boyunca karodan
".toaster" öneki şart: sonner kendi stilini çalışma anında enjekte eder, merkeze akar. Uç noktalar satır içi --x1/--y1/--x2/--y2 ile gelir (SVG
aynı özgüllükteki kural onun altında kalır. */ içinde px = viewBox birimi). Yalnız ekrandayken oynar. */
.toaster [data-sonner-toast][data-styled="true"] { @keyframes kt-akis {
border-radius: 1rem; 0% {
border-color: var(--color-slate-200); translate: var(--x1) var(--y1);
opacity: 0;
} }
.toaster [data-sonner-toast][data-styled="true"] [data-title] { 12%,
font-weight: 600; 82% {
color: var(--color-slate-900); opacity: 1;
} }
.toaster [data-sonner-toast][data-styled="true"] [data-description] { 100% {
color: var(--color-slate-600); translate: var(--x2) var(--y2);
opacity: 0;
} }
.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); .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;
}
[data-oynuyor] .kt-akis-kare {
animation-play-state: running;
}
@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 Script from "next/script";
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google"; import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
import localFont from "next/font/local"; 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 { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner"; import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy"; import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
@@ -129,7 +129,7 @@ export default function RootLayout({
<TercihProfiliKapisiLazy /> <TercihProfiliKapisiLazy />
{/* İlk ziyarette bir kez: çerez bilgilendirmesi (onay penceresi değil) */} {/* İlk ziyarette bir kez: çerez bilgilendirmesi (onay penceresi değil) */}
<CerezBilgi /> <CerezBilgi />
<Toaster position="top-center" /> <BildirimYigini />
{/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */} {/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */}
<ConsoleAsr /> <ConsoleAsr />
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */} {/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}

View File

@@ -1,16 +1,12 @@
import { import {
ArrowRight, ArrowRight,
ArrowUpRight, ArrowUpRight,
BarChart3,
Coins, Coins,
Compass,
FileText, FileText,
ListChecks, ListChecks,
MessageCircleQuestion, MessageCircleQuestion,
RefreshCcw, RefreshCcw,
ShieldCheck, ShieldCheck,
TrendingUp,
Wallet,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
@@ -22,9 +18,13 @@ import { Parallax } from "@/components/parallax";
import { Reveal } from "@/features/pazarlama/components/reveal"; import { Reveal } from "@/features/pazarlama/components/reveal";
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit"; import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map"; 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 { import {
PixelCorner,
PixelDivider, PixelDivider,
PixelRakam,
PixelSahne,
SectionEyebrow, SectionEyebrow,
} from "@/components/pixel-decor"; } from "@/components/pixel-decor";
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi"; import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
@@ -41,12 +41,6 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -58,11 +52,9 @@ import {
import type { Metadata } from "next"; import type { Metadata } from "next";
import { import {
JsonLd, JsonLd,
KATALOG_VERI_TARIHI,
faqPageJsonLd, faqPageJsonLd,
softwareApplicationJsonLd, softwareApplicationJsonLd,
} from "@/lib/seo"; } from "@/lib/seo";
import { KATALOG_TARIH_METNI } from "@/features/katalog/components/veri-kaynagi";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -76,20 +68,55 @@ const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0, 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 = [ const problems = [
{ {
icon: ListChecks, sahne: SAHNE_IHTIMAL,
title: "24 tercih, binlerce 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.`, 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", title: "Geçen yılın puanı yetmiyor",
description: description:
"Taban sıralamaları her yıl kayıyor; tek yıla bakmak riski gizler.", "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", title: "Danışman lüks haline geldi",
description: 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.", "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 = [ const steps = [
{ {
icon: Compass, step: 1,
step: "1",
title: "Sıralamanı ve hedeflerini anlat", title: "Sıralamanı ve hedeflerini anlat",
description: description:
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.", "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`, title: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
description: 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.", "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", title: "Dengeli 24'lük listeni al",
description: 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.", "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 = [ const faqs = [
@@ -217,6 +252,10 @@ const paketOzellikleri = [
] as const; ] as const;
export default function Home() { export default function Home() {
// Katalog hapları gerçek veriden (landing-verisi.ts); veri okunamazsa
// bölüm çizilmez.
const haplar = katalogHaplari();
return ( return (
<div <div
id="top" id="top"
@@ -257,27 +296,6 @@ export default function Home() {
> >
<HeroForm /> <HeroForm />
</div> </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> </Parallax>
</section> </section>
@@ -303,23 +321,24 @@ export default function Home() {
</p> </p>
</Reveal> </Reveal>
</Parallax> </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"> <Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
{problems.map((p, i) => ( {problems.map((p, i) => (
<Reveal key={p.title} delay={i * 0.08}> <Reveal key={p.title} delay={i * 0.08} className="h-full">
<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"> <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">
<PixelCorner className="absolute right-4 top-4" /> <div className="border-b border-slate-100 bg-slate-50 px-6 py-8">
<CardHeader> <PixelSahne izgara={p.sahne} />
<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 />
</div> </div>
<CardTitle className="font-heading text-xl"> <div className="p-6">
<h3 className="font-heading text-xl font-bold text-balance">
{p.title} {p.title}
</CardTitle> </h3>
<CardDescription className="text-base leading-relaxed"> <p className="mt-2 text-base leading-relaxed text-slate-600">
{p.description} {p.description}
</CardDescription> </p>
</CardHeader> </div>
</Card> </div>
</Reveal> </Reveal>
))} ))}
</Parallax> </Parallax>
@@ -361,78 +380,83 @@ export default function Home() {
</p> </p>
</Reveal> </Reveal>
</Parallax> </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) => ( {steps.map((s, i) => (
<Reveal key={s.step} delay={i * 0.08} className="h-full"> <li key={s.step}>
<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"> <Reveal
<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"> delay={i * 0.08}
{s.step} className="relative flex flex-col items-center text-center"
</span> >
<PixelCorner className="absolute right-4 top-4" /> <PixelRakam rakam={s.step} className="h-24 w-auto" />
<s.icon className="size-6 text-primary" aria-hidden /> {i < steps.length - 1 ? (
<h3 className="mt-4 font-heading text-xl font-semibold"> <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} {s.title}
</h3> </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} {s.description}
</p> </p>
</div>
</Reveal> </Reveal>
</li>
))} ))}
</ol>
</Parallax> </Parallax>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */} {/* Veri akışı + veriye dayalı güven + dürüstlük notu (DESIGN.md
<Parallax strength={10} className="mx-auto mt-12 w-full max-w-3xl"> §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 <Reveal
delay={0.1} 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"> <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">
value: PROGRAM_SAYISI_METNI, {veriSayilari.map((stat) => (
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) => (
// Sayı kahramandır (DESIGN.md §1.1): iri sayı üstte, etiket // 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. // altında; dt anlam için DOM'da önce, görsel sıra order ile.
<div <div key={stat.label} className="flex flex-col">
key={stat.label}
className="flex flex-col p-6 text-center"
>
<dt className="order-2 mt-2 text-sm font-semibold text-slate-500"> <dt className="order-2 mt-2 text-sm font-semibold text-slate-500">
{stat.label} {stat.label}
</dt> </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.value}
{stat.birim ? (
<span className="ml-1.5 font-sans text-base font-semibold tracking-normal text-slate-400">
{stat.birim}
</span>
) : null}
</dd> </dd>
</div> </div>
))} ))}
</dl> </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="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">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white shadow-sm"> <SectionEyebrow>Dürüstlük notu</SectionEyebrow>
<ShieldCheck className="size-5" aria-hidden /> <h3 className="mt-4 font-heading text-2xl font-bold text-balance text-slate-900">
</div> Yerleşme garantisi vermiyoruz —{" "}
<div> <span className="font-bricolage font-bold tracking-tighter text-primary italic">
<p className="text-sm font-semibold text-primary"> çünkü kimse veremez.
Dürüstlük notu </span>
</p> </h3>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl"> <p className="mt-3 text-base leading-relaxed text-slate-600">
Yerleşme garantisi vermiyoruz — çünkü kimse veremez.
</p>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
Taban sıralamaları her yıl değişir. Biz sonuç vaadi satmak 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 yerine riski satır satır gösterir, daha güvenli bir liste
kurmana yardımcı oluruz. kurmana yardımcı oluruz.
</p> </p>
</div> </div>
</div>
</Reveal> </Reveal>
</Parallax> </Parallax>
</div> </div>
@@ -668,6 +692,47 @@ export default function Home() {
</div> </div>
</section> </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 */} {/* Fiyat → SSS geçişi */}
<div aria-hidden className="mx-auto w-56"> <div aria-hidden className="mx-auto w-56">
<Parallax strength={26}> <Parallax strength={26}>

View File

@@ -10,7 +10,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Wrench, X } from "lucide-react"; import { Wrench, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "@/components/ui/bildirim";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil"; import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
import { 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. */ /** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({ export function SectionEyebrow({
children, 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 { animate, useReducedMotion } from "motion/react";
import { ChevronRight, GraduationCap, X } from "lucide-react"; import { ChevronRight, GraduationCap, X } from "lucide-react";
import { import {
Drawer, AltSayfa,
DrawerClose, AltSayfaKapat,
DrawerContent, AltSayfaGovde,
DrawerDescription, AltSayfaIcerik,
DrawerTitle, AltSayfaAciklama,
DrawerTrigger, AltSayfaBaslik,
} from "@/components/ui/drawer"; AltSayfaTetik,
} from "@/components/ui/alt-sayfa";
/** /**
* Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı * Ü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; children: ReactNode;
}) { }) {
return ( return (
<Drawer handleOnly> <AltSayfa>
<DrawerTrigger asChild> <AltSayfaTetik asChild>
<button <button
type="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" 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 aria-hidden
/> />
</button> </button>
</DrawerTrigger> </AltSayfaTetik>
<DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"> <AltSayfaIcerik>
<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 /> <X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span> <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"> <AltSayfaGovde className="px-4 pt-3 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"> <AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{baslik} {baslik}
</DrawerTitle> </AltSayfaBaslik>
<DrawerDescription className="mt-1 px-10 text-center text-sm text-slate-500"> <AltSayfaAciklama className="mt-1 px-10 text-center text-sm text-slate-500">
{baglam} {baglam}
</DrawerDescription> </AltSayfaAciklama>
<AcilisSahnesi>{children}</AcilisSahnesi> <AcilisSahnesi>{children}</AcilisSahnesi>
</div> </AltSayfaGovde>
</DrawerContent> </AltSayfaIcerik>
</Drawer> </AltSayfa>
); );
} }

View File

@@ -5,7 +5,6 @@ import {
Armchair, Armchair,
Briefcase, Briefcase,
Calculator, Calculator,
ChartLine,
ChevronRight, ChevronRight,
GraduationCap, GraduationCap,
Landmark, Landmark,
@@ -35,11 +34,7 @@ import { VeriKaynagi } from "./veri-kaynagi";
import { NavBaslik } from "./nav-baslik"; import { NavBaslik } from "./nav-baslik";
import { AkademikAyrinti } from "./akademik-ayrinti"; import { AkademikAyrinti } from "./akademik-ayrinti";
import { OzetKart, kartSayisi } from "@/components/ozet-kart"; import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import { import { KartBaglantisi, kisaUniAdi } from "./taban-karti";
KartBaglantisi,
kisaUniAdi,
ortaTabanAciklamasi,
} from "./taban-karti";
import { trBaslikDuzeni } from "@/lib/slug"; import { trBaslikDuzeni } from "@/lib/slug";
import { import {
BOLUM_ISTIHDAM_KAYNAK_URL, BOLUM_ISTIHDAM_KAYNAK_URL,
@@ -106,7 +101,7 @@ function MezunIstihdami({
const istihdamKaresi = const istihdamKaresi =
veri.istihdam != null ? ( veri.istihdam != null ? (
<div data-kare="sol" className={`${kare} min-h-40 bg-orange-500 py-4`}> <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> <dd className={`mt-3 ${devSayi}`}>%{ondalik(veri.istihdam)}</dd>
{ort.istihdam != null ? ( {ort.istihdam != null ? (
<dd className="mt-2 text-base font-bold text-white/90"> <dd className="mt-2 text-base font-bold text-white/90">
@@ -165,7 +160,7 @@ function MezunIstihdami({
> >
Türkiye geneli, tüm üniversitelerin{" "} Türkiye geneli, tüm üniversitelerin{" "}
{duzey === "lisans" ? "lisans" : "ön lisans"} mezunları birlikte; {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ş çalışanların oranıdır. Kazanç grubu bölümler arası kıyastır, maaş
değildir. değildir.
</p> </p>
@@ -409,10 +404,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []), ...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
]; ];
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi // Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
// çekmiyor): öğrencinin sorusuna cevap veren dört sayı — en seçici // çekmiyor): öğrencinin sorusuna cevap veren üç sayı — en yüksek taban,
// program, devlette en geniş kapı, orta tabanın yıllar içindeki seyri ve // devlette en düşük taban ve boş kalan kontenjan. Orta taban kartı
// boş kalan koltuk. Hesap lib/taban-ozeti.ts; program/devlet/vakıf sayıları // kaldırıldı (Bilal, 6 Eki 2026). Hesap lib/taban-ozeti.ts; program/
// özet paragrafta kalır. Üniversite sayfasıyla aynı düzen (DESIGN.md §4.8). // devlet/vakıf sayıları özet paragrafta kalır. Üniversite sayfasıyla aynı
// kartlar (DESIGN.md §4.8).
const { taban } = bolum; const { taban } = bolum;
const uniBaglantisi = (universite: string) => ( const uniBaglantisi = (universite: string) => (
<KartBaglantisi <KartBaglantisi
@@ -554,13 +550,14 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} /> <CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
</div> </div>
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin {/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8); burada üç.
sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */} Telefonda iki sütunda tek kalan üçüncü kart satırı boylu boyunca
alır. Derin sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
{ilkSayfa ? ( {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 <OzetKart
ikon={Target} ikon={Target}
etiket="En seçici" etiket="En yüksek taban"
{...(taban.enSecici {...(taban.enSecici
? kartSayisi(taban.enSecici.sira) ? kartSayisi(taban.enSecici.sira)
: { deger: "—" })} : { 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. */} kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
<OzetKart <OzetKart
ikon={Landmark} ikon={Landmark}
etiket="En geniş kapı" etiket="En düşük taban"
{...(taban.enGenis {...(taban.enGenis
? kartSayisi(taban.enGenis.sira) ? kartSayisi(taban.enGenis.sira)
: { deger: "—" })} : { 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 <OzetKart
ikon={Armchair} ikon={Armchair}
etiket="Boş koltuk" etiket="Boş kontenjan"
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"} deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
alt={ alt={
taban.doluluk taban.doluluk

View File

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

View File

@@ -1,47 +1,14 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; 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 * Ü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 * 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 * taban") ve orta taban kartı 6 Eki 2026'da Bilal'in isteğiyle kaldırıldı;
* git geçmişinde `taban-yillari.tsx` + `siranla-kapilar.tsx` olarak duruyor. * 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ğ). */ /** Özet kartı açıklamasındaki ad linki (üniversite ↔ bölüm çapraz bağ). */
export function KartBaglantisi({ export function KartBaglantisi({
href, href,

View File

@@ -5,7 +5,6 @@ import {
Armchair, Armchair,
ArrowRight, ArrowRight,
Building2, Building2,
ChartLine,
ChevronRight, ChevronRight,
Landmark, Landmark,
LayoutList, LayoutList,
@@ -57,7 +56,7 @@ import { UrapLogo } from "./urap-logo";
import { AkademikAyrinti } from "./akademik-ayrinti"; import { AkademikAyrinti } from "./akademik-ayrinti";
import { NavBaslik } from "./nav-baslik"; import { NavBaslik } from "./nav-baslik";
import { OzetKart, kartSayisi } from "@/components/ozet-kart"; import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import { KartBaglantisi, ortaTabanAciklamasi } from "./taban-karti"; import { KartBaglantisi } from "./taban-karti";
import { bolumBazAdi } from "@/lib/slug"; import { bolumBazAdi } from "@/lib/slug";
import { uniLogoYolu } from "@/lib/uni-logolar"; import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton"; 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 {/* Ş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 iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Dört
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026: kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
program/kontenjan adedi ilgi çekmiyor): en seçici bölüm program/kontenjan adedi ilgi çekmiyor): en yüksek ve en
adıyla, orta tabanın yıllar içindeki seyri, doluluk ve düşük taban bölüm adıyla, doluluk ve Türkiye sırası. Orta
Türkiye sırası. Program, birim ve kontenjan sayıları özet taban kartı kaldırıldı (Bilal, 6 Eki 2026). Program, birim
paragrafta. Hesap lib/taban-ozeti.ts. */} ve kontenjan sayıları özet paragrafta. Hesap
lib/taban-ozeti.ts. */}
<dl className="contents"> <dl className="contents">
<OzetKart <OzetKart
ikon={Target} ikon={Target}
etiket="En seçici bölüm" etiket="En yüksek taban"
{...(taban.enSecici {...(taban.enSecici
? kartSayisi(taban.enSecici.sira) ? kartSayisi(taban.enSecici.sira)
: { deger: "—" })} : { deger: "—" })}
@@ -708,15 +708,28 @@ export function UniversiteIcerik({
} }
/> />
<OzetKart <OzetKart
ikon={ChartLine} ikon={Landmark}
etiket="Orta taban" etiket="En düşük taban"
{...(() => { {...(taban.enGenis
const son = [...taban.ortaTaban] ? kartSayisi(taban.enGenis.sira)
.reverse() : { deger: "—" })}
.find((v) => v != null); alt={
return son != null ? kartSayisi(son) : { deger: "—" }; 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 ? ( {taban.doluluk ? (
<OzetKart <OzetKart
@@ -725,7 +738,7 @@ export function UniversiteIcerik({
deger={`%${taban.doluluk.yuzde}`} deger={`%${taban.doluluk.yuzde}`}
alt={ alt={
taban.doluluk.bosKoltuk > 0 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}` : `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
} }
/> />

View File

@@ -3,7 +3,7 @@
import { SayiRulo } from "@/components/ui/sayi-rulo"; import { SayiRulo } from "@/components/ui/sayi-rulo";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ArrowRight, MailCheck } from "lucide-react"; import { ArrowRight, MailCheck } from "lucide-react";
import { toast } from "sonner"; import { toast } from "@/components/ui/bildirim";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

View File

@@ -1,18 +1,18 @@
"use client"; "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. // monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
// //
// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır // Alt sayfa (alt-sayfa.tsx): tutamaçtan ya da başlıktan aşağı çekince, liste
// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması // en üstteyken aşağı çekince kapanır; Escape ve perde tıklaması da kapatır.
// da kapatır.
// //
// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi // Satır sıralama yalnız grip tutamağından başlar (dragControls). Satırı sola
// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları // kaydırmak "Çıkar" eylemini açar, sonuna kadar çekmek uygular
// sadeleşir; X butonu her durumda kalır. // (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 { useState } from "react";
import { toast } from "sonner"; import { toast } from "@/components/ui/bildirim";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { SayiRulo } from "@/components/ui/sayi-rulo"; import { SayiRulo } from "@/components/ui/sayi-rulo";
@@ -21,13 +21,14 @@ import { UniLogo } from "@/components/uni-logo";
import { RiskEtiketi } from "@/components/risk-etiketi"; import { RiskEtiketi } from "@/components/risk-etiketi";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Drawer, AltSayfa,
DrawerClose, AltSayfaBaslik,
DrawerContent, AltSayfaGovde,
DrawerFooter, AltSayfaIcerik,
DrawerHeader, AltSayfaKapat,
DrawerTitle, AltSayfaSurukle,
} from "@/components/ui/drawer"; } from "@/components/ui/alt-sayfa";
import { KaydirmaGrubu, KaydirmaSatiri } from "@/components/ui/kaydirma-eylemi";
import { import {
ArrowRight, ArrowRight,
Check, Check,
@@ -43,9 +44,7 @@ import {
motion, motion,
Reorder, Reorder,
useDragControls, useDragControls,
useMotionValue, useReducedMotion,
useTransform,
type PanInfo,
} from "motion/react"; } from "motion/react";
import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug"; import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri"; import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri";
@@ -120,7 +119,7 @@ export function ListeCekmecesi() {
const girisli = useOturumDurumu(); const girisli = useOturumDurumu();
const girisUrl = useGirisUrl(); const girisUrl = useGirisUrl();
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu // 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 [oturdu, setOturdu] = useState(false);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100); const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
@@ -144,7 +143,7 @@ export function ListeCekmecesi() {
} }
return ( return (
<Drawer <AltSayfa
open={acik} open={acik}
onOpenChange={(v) => { onOpenChange={(v) => {
if (v) cekmeceAc(); if (v) cekmeceAc();
@@ -153,16 +152,11 @@ export function ListeCekmecesi() {
cekmeceKapat(); 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; {/* 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 çarpı §4.3 kanonik sınıf setinde, temizle düğmesi aynı ölçüde
solda. */} solda. Başlık alanından da tutulup aşağı çekilir. */}
<DrawerHeader className="relative border-b border-slate-100 px-4 pt-5 pb-4 text-center md:text-center">
{liste.length > 0 ? ( {liste.length > 0 ? (
<button <button
type="button" type="button"
@@ -179,13 +173,14 @@ export function ListeCekmecesi() {
<span className="sr-only">Listeyi temizle</span> <span className="sr-only">Listeyi temizle</span>
</button> </button>
) : null} ) : 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 /> <X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span> <span className="sr-only">Kapat</span>
</DrawerClose> </AltSayfaKapat>
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900"> <AltSayfaSurukle className="border-b border-slate-100 px-4 pt-3 pb-4 text-center">
<AltSayfaBaslik className="px-10 text-center">
Kendi Listem Kendi Listem
</DrawerTitle> </AltSayfaBaslik>
{/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3). Bilal'in {/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3). Bilal'in
kararı (24 Eyl 2026): buradaki piksel liste başlığı çok dolu kararı (24 Eyl 2026): buradaki piksel liste başlığı çok dolu
gösteriyordu; ilerleme çubuğuna geri dönüldü. */} 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. Güvenli yok; alta birkaç tane eklemek riski azaltır.
</p> </p>
) : null} ) : null}
</DrawerHeader> </AltSayfaSurukle>
<div <AltSayfaGovde className="overflow-x-hidden px-4 py-2">
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-2"
>
{liste.length === 0 ? ( {liste.length === 0 ? (
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center"> <div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
<ListChecks <ListChecks
@@ -258,6 +251,7 @@ export function ListeCekmecesi() {
</p> </p>
</div> </div>
) : ( ) : (
<KaydirmaGrubu>
<Reorder.Group <Reorder.Group
axis="y" axis="y"
values={liste} values={liste}
@@ -276,6 +270,7 @@ export function ListeCekmecesi() {
))} ))}
</AnimatePresence> </AnimatePresence>
</Reorder.Group> </Reorder.Group>
</KaydirmaGrubu>
)} )}
{liste.length < MANUEL_LISTE_MAX ? ( {liste.length < MANUEL_LISTE_MAX ? (
@@ -290,9 +285,9 @@ export function ListeCekmecesi() {
Program ekle Program ekle
</button> </button>
) : null} ) : 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 {/* 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. */} hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
{girisli === false ? ( {girisli === false ? (
@@ -373,15 +368,12 @@ export function ListeCekmecesi() {
</Button> </Button>
)} )}
</CtaBandi> </CtaBandi>
</DrawerFooter> </div>
</DrawerContent> </AltSayfaIcerik>
</Drawer> </AltSayfa>
); );
} }
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
const SIL_ESIK = 90;
function CekmeceSatiri({ function CekmeceSatiri({
tercih, tercih,
sira, sira,
@@ -392,19 +384,9 @@ function CekmeceSatiri({
projeksiyonAcik: boolean; projeksiyonAcik: boolean;
}) { }) {
const siraKontrol = useDragControls(); const siraKontrol = useDragControls();
const azalt = useReducedMotion();
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı const cikarIste = () =>
const x = useMotionValue(0);
const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
const kaydirinca = (_: unknown, info: PanInfo) => {
if (
info.offset.x > SIL_ESIK ||
(info.offset.x > 32 && info.velocity.x > 500)
) {
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`); geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
}
};
return ( return (
<Reorder.Item <Reorder.Item
@@ -414,36 +396,34 @@ function CekmeceSatiri({
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined} layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
dragListener={false} dragListener={false}
dragControls={siraKontrol} 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 }} 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 }} transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{ scale: 1.03, zIndex: 10 }} 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 */} {/* Sola kaydır: "Çıkar" açılır; sonuna kadar çekince uygulanır (§4.23) */}
<motion.div <KaydirmaSatiri
style={{ opacity: silGorunur }} sag={[
className="absolute inset-0 flex items-center bg-red-500 pl-4" {
aria-hidden etiket: "Çıkar",
> ikon: <CircleMinus aria-hidden />,
<Trash2 className="size-4 text-white" /> ton: "tehlike",
</motion.div> onSec: cikarIste,
},
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */} ]}
<motion.div icerikSinifi="flex items-center gap-3 bg-card py-3"
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"
> >
<button <button
type="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) => { onPointerDown={(e) => {
e.stopPropagation(); // yatay kaydırmayı tetiklemesin e.stopPropagation();
siraKontrol.start(e); 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" 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 <Link
href={bolumHref(tercih.isim)} href={bolumHref(tercih.isim)}
onClick={cekmeceKapat} onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className={SATIR_AD} className={SATIR_AD}
> >
{tercih.isim} {tercih.isim}
@@ -468,7 +447,6 @@ function CekmeceSatiri({
<Link <Link
href={uniHref(tercih.universite)} href={uniHref(tercih.universite)}
onClick={cekmeceKapat} onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
title={`${tercih.universite} taban puanları sayfası`} title={`${tercih.universite} taban puanları sayfası`}
className={`min-w-0 ${SATIR_IKINCIL_LINK}`} className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
> >
@@ -491,18 +469,16 @@ function CekmeceSatiri({
</span> </span>
<button <button
type="button" type="button"
onClick={() => onClick={cikarIste}
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
// Satırdan çıkarma "−" ile, çerçevesiz: kapatma çarpısıyla (§4.3) // 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]" 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 /> <CircleMinus className="size-5" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span> <span className="sr-only">{tercih.isim} listeden çıkar</span>
</button> </button>
</motion.div> </KaydirmaSatiri>
</Reorder.Item> </Reorder.Item>
); );
} }

View File

@@ -2,7 +2,7 @@
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri"; import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { ArrowRight, Check, Plus } from "lucide-react"; import { ArrowRight, Check, Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "@/components/ui/bildirim";
import type { Program } from "@/types/yokatlas"; import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { import {

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight, Trophy } from "lucide-react"; import { ArrowRight, Trophy } from "lucide-react";
import { useReducedMotion } from "motion/react"; import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { toast } from "sonner"; import { toast } from "@/components/ui/bildirim";
import { TransitionPanel } from "@/components/core/transition-panel"; import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";

View File

@@ -3,7 +3,7 @@
import { useLayoutEffect, useState, useTransition } from "react"; import { useLayoutEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react"; import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "@/components/ui/bildirim";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { baslatOdeme } from "../odeme-actions"; 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 { useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "@/components/ui/bildirim";
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react"; import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

View File

@@ -8,12 +8,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { SecimHaplari } from "@/components/ui/secim-haplari"; import { SecimHaplari } from "@/components/ui/secim-haplari";
import { import {
Drawer, AltSayfa,
DrawerClose, AltSayfaKapat,
DrawerContent, AltSayfaGovde,
DrawerDescription, AltSayfaIcerik,
DrawerTitle, AltSayfaAciklama,
} from "@/components/ui/drawer"; AltSayfaBaslik,
} from "@/components/ui/alt-sayfa";
import { PikselZemin } from "@/components/piksel-zemin"; import { PikselZemin } from "@/components/piksel-zemin";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi"; import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { import {
@@ -305,19 +306,15 @@ export function CtaSiraForm({
// başlığı yalnız ekran okuyucuya. // başlığı yalnız ekran okuyucuya.
const siraAdimi = !kompakt && adim === "sira"; const siraAdimi = !kompakt && adim === "sira";
const cekmece = ( const cekmece = (
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly> <AltSayfa open={acik} onOpenChange={acikDegisti}>
<DrawerContent <AltSayfaIcerik>
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl" <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]">
>
<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]">
<X className="size-4" aria-hidden /> <X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span> <span className="sr-only">Kapat</span>
</DrawerClose> </AltSayfaKapat>
<div <AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
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" <AltSayfaBaslik
>
<DrawerTitle
className={ className={
siraAdimi siraAdimi
? "px-10 text-center font-heading text-2xl font-bold text-slate-900" ? "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? Sıralamana yakın programlar hangileri?
</DrawerTitle> </AltSayfaBaslik>
<DrawerDescription <AltSayfaAciklama
className={ className={
siraAdimi siraAdimi
? "mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500" ? "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 Puan türünü seç, sıralamanı yaz; hayal, dengeli ve güvenli
dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan
ayrılmadan gör. Ücretsiz, üyelik gerekmez. ayrılmadan gör. Ücretsiz, üyelik gerekmez.
</DrawerDescription> </AltSayfaAciklama>
{siraAdimi ? ( {siraAdimi ? (
<div className="mt-5">{siraFormu}</div> <div className="mt-5">{siraFormu}</div>
) : facetler && sira != null ? ( ) : facetler && sira != null ? (
@@ -351,9 +348,9 @@ export function CtaSiraForm({
onTamamla={secimleriTamamla} onTamamla={secimleriTamamla}
/> />
) : null} ) : null}
</div> </AltSayfaGovde>
</DrawerContent> </AltSayfaIcerik>
</Drawer> </AltSayfa>
); );
if (kompakt) { if (kompakt) {

View File

@@ -20,12 +20,9 @@ import {
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
profilDuzenlemeyiAc,
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas"; 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"; import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada. // 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. // 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ı // Sıralamayı değiştirmek navbar'daki sıralama hapından yapılır.
// (TercihProfiliKapisi, layout'ta monte) açar.
if (kayitliProfil) { if (kayitliProfil) {
return ( 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 <Button
asChild asChild
size="lg" size="lg"
@@ -122,18 +118,6 @@ export function HeroForm() {
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Link> </Link>
</Button> </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> </div>
); );
} }

View File

@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, Search, X } from "lucide-react"; import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
import { useReducedMotion } from "motion/react"; import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure"; 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 { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel"; import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -23,47 +23,8 @@ import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/types/yokatlas"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler"; 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"; import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum";
// Seçim çipi tek kaynaktan (DESIGN.md §4.24): seçilince tik büyür, yazı kayar.
export function Cip({ import { CipGrubu, SecimCipi as Cip } from "@/components/ui/cip-grubu";
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>
);
}
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const; const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
@@ -337,7 +298,7 @@ export function SihirbazAdimlar({
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç. Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p> </p>
<div className="mt-4 flex flex-wrap gap-2"> <CipGrubu etiket="İlgi alanları" className="mt-4">
{facetler.kategoriler.map((k) => ( {facetler.kategoriler.map((k) => (
<Cip <Cip
key={k.ad} key={k.ad}
@@ -348,7 +309,7 @@ export function SihirbazAdimlar({
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span> <span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
</Cip> </Cip>
))} ))}
</div> </CipGrubu>
</div> </div>
<div ref={panelRef}> <div ref={panelRef}>
<h3 className="font-heading text-xl font-bold text-slate-900"> <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 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ı yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
devre dışı bırakır. */} devre dışı bırakır. */}
<div <CipGrubu
className={`mt-3 flex max-h-64 flex-wrap gap-2 overflow-y-auto sm:max-h-none sm:overflow-visible ${ etiket="Şehirler"
className={`mt-3 max-h-64 overflow-y-auto sm:max-h-none sm:overflow-visible ${
facetYukleniyor ? "animate-pulse opacity-60" : "" facetYukleniyor ? "animate-pulse opacity-60" : ""
}`} }`}
> >
@@ -424,7 +386,7 @@ export function SihirbazAdimlar({
Daha az göster Daha az göster
</button> </button>
) : null} ) : null}
</div> </CipGrubu>
{ilAramaNorm && {ilAramaNorm &&
gorunenTurkiyeIlleri.length === 0 && gorunenTurkiyeIlleri.length === 0 &&
@@ -439,8 +401,9 @@ export function SihirbazAdimlar({
<p className="mb-2 text-sm font-semibold text-slate-700"> <p className="mb-2 text-sm font-semibold text-slate-700">
KKTC & Yurt Dışı KKTC & Yurt Dışı
</p> </p>
<div <CipGrubu
className={`flex flex-wrap gap-2 ${ etiket="KKTC ve yurt dışı"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : "" 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> <span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
</Cip> </Cip>
))} ))}
</div> </CipGrubu>
</div> </div>
) : null} ) : null}
@@ -500,8 +463,9 @@ export function SihirbazAdimlar({
<p className="mb-2 text-sm font-semibold text-slate-700"> <p className="mb-2 text-sm font-semibold text-slate-700">
Üniversite tipi Üniversite tipi
</p> </p>
<div <CipGrubu
className={`flex flex-wrap gap-2 ${ etiket="Üniversite tipi"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : "" facetYukleniyor ? "animate-pulse opacity-60" : ""
}`} }`}
> >
@@ -525,7 +489,7 @@ export function SihirbazAdimlar({
> >
Fark etmez Fark etmez
</Cip> </Cip>
</div> </CipGrubu>
</div> </div>
<div> <div>
<p className="mb-2 text-sm font-semibold text-slate-700"> <p className="mb-2 text-sm font-semibold text-slate-700">
@@ -533,7 +497,7 @@ export function SihirbazAdimlar({
</p> </p>
{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda {/* 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. */} 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) => ( {ONCELIKLER.map((o) => (
<Cip <Cip
key={o} key={o}
@@ -544,7 +508,7 @@ export function SihirbazAdimlar({
{o} {o}
</Cip> </Cip>
))} ))}
</div> </CipGrubu>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Listeyi daraltmaz; programların sırasını ve gerekçelerini Listeyi daraltmaz; programların sırasını ve gerekçelerini
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP, yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
@@ -559,7 +523,7 @@ export function SihirbazAdimlar({
Özel kontenjan durumun{" "} Özel kontenjan durumun{" "}
<span className="font-normal text-slate-500">(isteğe bağlı)</span> <span className="font-normal text-slate-500">(isteğe bağlı)</span>
</p> </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) => ( {OZEL_DURUMLAR.map((d) => (
<Cip <Cip
key={d} key={d}
@@ -577,7 +541,7 @@ export function SihirbazAdimlar({
{OZEL_DURUM_ETIKET[d]} {OZEL_DURUM_ETIKET[d]}
</Cip> </Cip>
))} ))}
</div> </CipGrubu>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
kontenjanı olan programlar listende öne çıkar ve danışman bunu 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);
}