diff --git a/components.json b/components.json index cb51d7a..cb47ab0 100644 --- a/components.json +++ b/components.json @@ -23,6 +23,7 @@ }, "registries": { "@skiper-ui": "https://skiper-ui.com/registry/{name}.json", - "@magicui": "https://magicui.design/r/{name}" + "@magicui": "https://magicui.design/r/{name}", + "@uiarc": "https://uiarc.dev/r/{name}.json" } } diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index 5a9faa6..91d6a88 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -686,6 +686,26 @@ genişletilebilir olmalı; UI için prompt-kit kullan." zaman yazılıdır (§2.2). Sohbet ilerlese de kalır. Yeni bir liste uyarısı gerekince `listeUyarilari`'na eklenir, sayfaya yeni kutu açılmaz. +### 4.21 Sayı rulosu — `SayiRulo`, `src/components/ui/sayi-rulo.tsx` +Ekranda **değişen** sayı (üst çubuk rozeti ve kredi hapı, "Listende 5/24", çekmece sayacı, +danışman kredi rozeti, "40 / 312 program", üst şeridin sezon geri sayımı, giriş formunun +"Yeniden gönder (59)" sayacı, kaydet bildirimindeki seçim sayısı) rulo gibi döner: +eklemede yukarı, çıkarmada aşağı; basamak artınca yeni sütun yayla açılır. Yazı boyu/rengi +kapsayıcıdan gelir. Yay §6 varsayılanı; hareketi azalt'ta düz metin; ekran okuyucu düz metni +okur. Kaynak Arc UI "Number field" (uiarc.dev, 6 Eki 2026); oradaki −/+ adım butonları ve +sürükleme alınmadı — sıralama girişi `Input` hapı olarak kalır (§4.6), rulo yalnız gösterime +takılır. Durağan sayıya (taban sıralaması, kontenjan) takılmaz. Geri sayım gibi basamak +dolgusu isteyen sayaçta `enAzBasamak={2}` ("05") ve sabit `yon={-1}` (00 → 59 sarması da aşağı +döner) verilir; sitede ikinci bir rakam kütüphanesi yoktur (`@number-flow/react` 6 Eki 2026'da +kaldırıldı, üst şerit de bu ruloyu kullanır). + +### 4.22 Kayan vurgulu seçim hapları — `SecimHaplari`, `src/components/ui/secim-haplari.tsx` +Tek seçimli hap grubunda seçili vurgu yeniden boyanmaz, tek yüzey olarak seçenekten seçeneğe +**kayar** (`layoutId`). `ayrik`: kenarlıklı ayrı haplar + mavi dolu vurgu (puan türü çipleri — +hero ve CTA formu). `kapsul`: tek kapsülde koyu hap, yazı `text-slate-50` (tema seçici). Yay §6; +hareketi azalt'ta vurgu yerinde belirir. Kaynak Arc UI "Chip group" (6 Eki 2026). Çok seçimli +çipler (sihirbaz alan/il) bu desene girmez — orada her çip kendi başına yanar. + ## 5. Yerleşim ### 5.1 Genel diff --git a/package.json b/package.json index b44556d..e2ad7c0 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@ai-sdk/react": "^4.0.130", "@cds/city": "^1.1.0", "@libsql/client": "^0.17.4", - "@number-flow/react": "^0.6.2", "@shadcn/helpers": "^0.2.0", "ai": "^7.0.127", "better-auth": "^1.6.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de81d55..3bb9b06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: '@libsql/client': specifier: ^0.17.4 version: 0.17.4 - '@number-flow/react': - specifier: ^0.6.2 - version: 0.6.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@shadcn/helpers': specifier: ^0.2.0 version: 0.2.0(ai@7.0.127(zod@4.4.3)) @@ -1289,12 +1286,6 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@number-flow/react@0.6.2': - resolution: {integrity: sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==} - peerDependencies: - react: ^18 || ^19 - react-dom: ^18 || ^19 - '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -3547,9 +3538,6 @@ packages: jiti: optional: true - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4692,9 +4680,6 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - number-flow@0.6.2: - resolution: {integrity: sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==} - oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} @@ -6693,13 +6678,6 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@number-flow/react@0.6.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - esm-env: 1.2.2 - number-flow: 0.6.2 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - '@opentelemetry/semantic-conventions@1.43.0': {} '@postman/form-data@3.1.1': @@ -9109,8 +9087,6 @@ snapshots: transitivePeerDependencies: - supports-color - esm-env@1.2.2: {} - espree@10.4.0: dependencies: acorn: 8.17.0 @@ -10435,10 +10411,6 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - number-flow@0.6.2: - dependencies: - esm-env: 1.2.2 - oauth-sign@0.9.0: {} object-assign@4.1.1: {} diff --git a/src/components/site-top-banner.tsx b/src/components/site-top-banner.tsx index 52b9032..589fbc9 100644 --- a/src/components/site-top-banner.tsx +++ b/src/components/site-top-banner.tsx @@ -1,7 +1,7 @@ "use client"; +import { SayiRulo } from "@/components/ui/sayi-rulo"; import { startTransition, useEffect, useState } from "react"; -import NumberFlow, { NumberFlowGroup } from "@number-flow/react"; import { CalendarDays, Clock3 } from "lucide-react"; import { BANT_ARIA, @@ -131,45 +131,28 @@ export function SiteTopBanner() { : "Geri sayım yükleniyor" } > - - {BIRIMLER.map(({ anahtar, etiket }) => ( -
-
{etiket}
-
- {geriSayim ? ( - - ) : ( - "--" - )} - - {etiket} - -
-
- ))} -
+ {/* Rulo rakam (DESIGN.md §4.21): her birim kendi tekerleğinde + aşağı döner; 00 → 59 sarması da aşağı (yon=-1). */} + {BIRIMLER.map(({ anahtar, etiket }) => ( +
+
{etiket}
+
+ {geriSayim ? ( + + ) : ( + "--" + )} + + {etiket} + +
+
+ ))} ) : null} diff --git a/src/components/tema-secici.tsx b/src/components/tema-secici.tsx index f4a2e0d..9290305 100644 --- a/src/components/tema-secici.tsx +++ b/src/components/tema-secici.tsx @@ -3,20 +3,22 @@ import { useSyncExternalStore } from "react"; import { useTheme } from "next-themes"; import { Monitor, Moon, Sun } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { SecimHaplari } from "@/components/ui/secim-haplari"; const SECENEKLER = [ { deger: "light", etiket: "Açık", ikon: Sun }, { deger: "dark", etiket: "Koyu", ikon: Moon }, { deger: "system", etiket: "Sistem", ikon: Monitor }, ] as const; +type Tema = (typeof SECENEKLER)[number]["deger"]; const aboneOlma = () => () => {}; /** * Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca * tarayıcıda bilinir; sunucu HTML'inde hiçbir seçenek işaretli gelmez, - * hydration sonrası işaretlenir (yerleşim oynamaz). + * hydration sonrası işaretlenir (yerleşim oynamaz). Koyu vurgu seçenekten + * seçeneğe kayar (secim-haplari.tsx, kapsül varyantı). */ export function TemaSecici({ className }: { className?: string }) { const { theme, setTheme } = useTheme(); @@ -25,37 +27,19 @@ export function TemaSecici({ className }: { className?: string }) { () => true, () => false, ); + const secili = tarayicida + ? (SECENEKLER.find((s) => s.deger === theme)?.deger ?? null) + : null; return ( -
- {SECENEKLER.map(({ deger, etiket, ikon: Ikon }) => { - const secili = tarayicida && theme === deger; - return ( - - ); - })} -
+ + ariaLabel="Tema" + secenekler={SECENEKLER} + deger={secili} + onChange={setTheme} + varyant="kapsul" + boyut="md" + className={className} + /> ); } diff --git a/src/components/ui/sayi-rulo.tsx b/src/components/ui/sayi-rulo.tsx new file mode 100644 index 0000000..0dd7ad1 --- /dev/null +++ b/src/components/ui/sayi-rulo.tsx @@ -0,0 +1,172 @@ +"use client"; + +// Rulo rakam (odometer): sayı değişince her basamak kendi tekerleğinde döner — +// artarken yukarı, azalırken aşağı; 9 → 0 sarar, 10 → 9 geri döner. Basamak +// sayısı değişince (9 → 10) yeni sütun genişliğini yayla açar, kalkan sütun +// kapanır; sütunlar basamak değerine göre anahtarlı olduğundan birler hanesi +// hep birler hanesi kalır. +// +// Kaynak: Arc UI "Number field" (uiarc.dev) rakam rulosu. Oradaki adım +// butonları, sürükleyerek değiştirme ve sınır notu alınmadı — 6 haneli +// sıralama girişine uymuyor; rulo yalnız EKRANDA DEĞİŞEN sayıya takılır +// (liste sayacı gibi). Yay DESIGN.md §6 varsayılanı (sekmesiz, 0,35 s); +// hareketi azalt'ta düz metin. Ekran okuyucu düz metni okur, rulo aria-hidden. + +import { useLayoutEffect, useRef, useState } from "react"; +import { + AnimatePresence, + animate, + motion, + useMotionValue, + useReducedMotion, + useTransform, + type MotionValue, + type Variants, +} from "motion/react"; +import { cn } from "@/lib/utils"; + +const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const; +const RAKAMLAR = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; +const BICIMLER = new Map(); +/** Basamak dolgusu isteyen sayaç (geri sayım "05") binlik ayracı kullanmaz. */ +function bicimAl(enAzBasamak: number): Intl.NumberFormat { + let b = BICIMLER.get(enAzBasamak); + if (!b) { + b = new Intl.NumberFormat("tr-TR", { + numberingSystem: "latn", + minimumIntegerDigits: enAzBasamak, + useGrouping: enAzBasamak <= 1, + }); + BICIMLER.set(enAzBasamak, b); + } + return b; +} + +type Parca = + | { anahtar: string; rakam: number } + | { anahtar: string; metin: string }; + +/** Biçimlenmiş sayıyı basamak değerine göre anahtarlı sütunlara böler. */ +function parcala(deger: number, bicim: Intl.NumberFormat): Parca[] { + const parcalar = bicim.formatToParts(deger); + let basamak = parcalar.reduce( + (n, p) => n + (p.type === "integer" ? p.value.length : 0), + 0, + ); + return parcalar.flatMap((p, i): Parca[] => { + if (p.type === "integer") + return [...p.value].map((c) => ({ anahtar: `b${--basamak}`, rakam: Number(c) })); + return [ + { anahtar: p.type === "group" ? `g${basamak}` : `${p.type}${i}`, metin: p.value }, + ]; + }); +} + +// Sütun açılırken değişim yönünde yükselir, kapanırken aynı yoldan gider (§6: +// giriş ve çıkış aynı yoldan). +const yuva: Variants = { + giris: (yon: number) => ({ width: 0, opacity: 0, y: `${yon * 0.3}em` }), + merkez: { width: "auto", opacity: 1, y: 0, transition: YAY }, + cikis: (yon: number) => ({ + width: 0, + opacity: 0, + y: `${yon * -0.3}em`, + transition: YAY, + }), +}; + +/** Tekerlekteki bir rakam: tekerleğin konumuna uzaklığı yerini ve görünürlüğünü belirler. */ +function Glif({ konum, rakam }: { konum: MotionValue; rakam: number }) { + const uzaklik = (simdiki: number) => + ((((rakam - simdiki) % 10) + 15) % 10) - 5; + const y = useTransform(konum, (c) => `${uzaklik(c) * 1.05}em`); + const opacity = useTransform(konum, (c) => + Math.max(0, 1 - Math.abs(uzaklik(c)) ** 1.5 * 1.1), + ); + const visibility = useTransform(konum, (c) => + Math.abs(uzaklik(c)) >= 1 ? "hidden" : "visible", + ); + return ( + + {rakam} + + ); +} + +/** Bir basamağın tekerleği: sayının gittiği yönde döner, yarım turdan uzunsa kısa yolu seçer. */ +function Tekerlek({ rakam, yon }: { rakam: number; yon: number }) { + const konum = useMotionValue(rakam); + const durum = useRef({ rakam, hedef: rakam }); + useLayoutEffect(() => { + const d = durum.current; + if (d.rakam === rakam) return; + let fark = + yon > 0 ? (rakam - d.rakam + 10) % 10 : -((d.rakam - rakam + 10) % 10); + if (Math.abs(fark) > 5) fark -= Math.sign(fark) * 10; + d.hedef += fark; + d.rakam = rakam; + animate(konum, d.hedef, YAY); + }, [rakam, yon, konum]); + return ( + <> + 0 + {RAKAMLAR.map((r) => ( + + ))} + + ); +} + +/** + * Ekranda değişen bir sayı (liste sayacı gibi). Yazı boyu/rengi/ağırlığı + * kapsayıcıdan gelir; `className` yalnız yerleşim için. + */ +export function SayiRulo({ + deger, + className, + enAzBasamak = 1, + yon: sabitYon, +}: { + deger: number; + className?: string; + /** Basamak dolgusu: 2 → "05". Üst çubuk geri sayımı; binlik ayracı kapanır. */ + enAzBasamak?: number; + /** Dönüş yönünü sabitler: geri sayımda 00 → 59 yukarı değil aşağı dönsün. */ + yon?: 1 | -1; +}) { + const azalt = useReducedMotion(); + const [iz, setIz] = useState({ deger, yon: 1 }); + if (iz.deger !== deger) setIz({ deger, yon: deger > iz.deger ? 1 : -1 }); + const yon = + sabitYon ?? (iz.deger === deger ? iz.yon : deger > iz.deger ? 1 : -1); + const bicim = bicimAl(enAzBasamak); + const metin = bicim.format(deger); + + if (azalt) return {metin}; + + return ( + + {metin} + + + {parcala(deger, bicim).map((p) => ( + + {"rakam" in p ? : p.metin} + + ))} + + + + ); +} diff --git a/src/components/ui/secim-haplari.tsx b/src/components/ui/secim-haplari.tsx new file mode 100644 index 0000000..b0777c3 --- /dev/null +++ b/src/components/ui/secim-haplari.tsx @@ -0,0 +1,104 @@ +"use client"; + +// Tek seçimli hap grubu (puan türü, tema): seçili vurgu ayrı ayrı yeniden +// boyanmak yerine TEK bir yüzey olarak seçenekten seçeneğe kayar +// (`layoutId`). Göz "hangisi seçili" sorusunu hareketin bittiği yerde okur. +// +// Kaynak: Arc UI "Chip group" / "Floating button group" (uiarc.dev) kayan +// vurgu deseni; sınıf dizisi DESIGN.md hap dili. İki varyant: +// - `ayrik`: her seçenek kendi kenarlıklı hapı, vurgu mavi dolu hap (hero ve +// CTA formundaki puan türü çipleri). +// - `kapsul`: tek kapsül içinde koyu hap (üst çubuk "Giriş yap" diliyle aynı, +// tema seçici). Koyu hap yazısı `text-slate-50` (§2.3). +// Yay §6 varsayılanı; hareketi azalt'ta vurgu kaymaz, yerinde belirir. + +import { useId, type ComponentType } from "react"; +import { motion, useReducedMotion } from "motion/react"; +import { cn } from "@/lib/utils"; + +const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const; + +export interface SecimHapiSecenek { + deger: T; + etiket: string; + ikon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>; +} + +export function SecimHaplari({ + secenekler, + deger, + onChange, + ariaLabel, + varyant = "ayrik", + boyut = "sm", + className, +}: { + secenekler: readonly SecimHapiSecenek[]; + /** `null`: henüz seçim yok (ör. tema hidrasyondan önce) — vurgu çizilmez. */ + deger: T | null; + onChange: (deger: T) => void; + ariaLabel: string; + varyant?: "ayrik" | "kapsul"; + boyut?: "sm" | "md"; + className?: string; +}) { + const uid = useId(); + const azalt = useReducedMotion(); + const kapsul = varyant === "kapsul"; + const yukseklik = boyut === "md" ? "h-9 px-3 text-sm" : "h-8 px-3 text-xs"; + + return ( +
+ {secenekler.map(({ deger: d, etiket, ikon: Ikon }) => { + const secili = deger === d; + return ( + + ); + })} +
+ ); +} diff --git a/src/features/kullanici/components/giris-form.tsx b/src/features/kullanici/components/giris-form.tsx index 614b65b..e21880b 100644 --- a/src/features/kullanici/components/giris-form.tsx +++ b/src/features/kullanici/components/giris-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { SayiRulo } from "@/components/ui/sayi-rulo"; import { useEffect, useRef, useState } from "react"; import { ArrowRight, MailCheck } from "lucide-react"; import { toast } from "sonner"; @@ -129,11 +130,15 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) { disabled={busy !== null || bekleme > 0} className="cursor-pointer font-medium text-primary underline-offset-2 hover:underline disabled:cursor-default disabled:text-slate-400 disabled:no-underline" > - {busy === "email" - ? "Gönderiliyor…" - : bekleme > 0 - ? `Yeniden gönder (${bekleme})` - : "Yeniden gönder"} + {busy === "email" ? ( + "Gönderiliyor…" + ) : bekleme > 0 ? ( + <> + Yeniden gönder () + + ) : ( + "Yeniden gönder" + )} ); diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx index 31631bb..1ba6711 100644 --- a/src/features/liste/components/program-tablosu.tsx +++ b/src/features/liste/components/program-tablosu.tsx @@ -22,6 +22,7 @@ import type { } from "@/types/yokatlas"; import { DILIM_ETIKET, riskHesapla } from "@/lib/risk"; import { bolumSayfaSlug, ilSayfaHref, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug"; +import { SayiRulo } from "@/components/ui/sayi-rulo"; import { TurCipi } from "@/components/tur-cipi"; import { cn } from "@/lib/utils"; import { VERI_YIL_SAYISI } from "@/lib/veri-yillari"; @@ -306,7 +307,7 @@ export function ProgramTablosu({ - {satirlar.length.toLocaleString("tr-TR")} /{" "} + /{" "} {toplam.toLocaleString("tr-TR")} program diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index a16d895..85b9f83 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { SayiRulo } from "@/components/ui/sayi-rulo"; import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { AracPilleri, aracPilleri } from "./arac-pilleri"; import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti"; @@ -643,7 +644,7 @@ export function SohbetClient({ title="Kredilerin — yüklemek için tıkla" > - {kredi} kredi + kredi diff --git a/src/features/sihirbaz/components/cta-sira-form.tsx b/src/features/sihirbaz/components/cta-sira-form.tsx index 4c34c7e..e2f3d26 100644 --- a/src/features/sihirbaz/components/cta-sira-form.tsx +++ b/src/features/sihirbaz/components/cta-sira-form.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { ArrowRight, Trophy, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { SecimHaplari } from "@/components/ui/secim-haplari"; import { Drawer, DrawerClose, @@ -37,6 +38,7 @@ const PUAN_TURLERI_SECENEK = [ { deger: "dil", etiket: "Dil" }, { deger: "tyt", etiket: "TYT" }, ] as const; +type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"]; // Kayıtlı profilli CTA'lar /listem boş durumunun dilini taşır (bkz. // listem-bos-cta.tsx): büyük turuncu buton. Sınıf seti tek yerde. @@ -138,7 +140,7 @@ export function CtaSiraForm({ const [acik, setAcik] = useState(false); const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira"); const [siralama, setSiralama] = useState(""); - const [tur, setTur] = useState("say"); + const [tur, setTur] = useState("say"); const [sira, setSira] = useState(null); const [facetler, setFacetler] = useState(null); const [yukleniyor, setYukleniyor] = useState(false); @@ -236,28 +238,14 @@ export function CtaSiraForm({ : "flex flex-col gap-3" } > -
- {PUAN_TURLERI_SECENEK.map((t) => ( - - ))} -
+ {/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */} +