feat(ui): Arc kayan vurgulu seçim hapları ve sayı rulosu

- SecimHaplari: tema seçici (kapsül) ve puan türü çipleri (hero + CTA formu);
  seçili vurgu layoutId ile seçenekten seçeneğe kayar.
- SayiRulo: değişen sayılar (listem rozeti, çekmece ve durum satırı sayacı,
  kredi hapları, dilim tablosu sayacı, giriş yeniden gönder sayacı, kaydet
  bandı, üst şerit geri sayımı) rulo gibi döner; @number-flow/react kaldırıldı.
- DESIGN.md §4.21–4.22, components.json @uiarc kaydı.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-06 19:40:30 +03:00
parent a3cb058a3b
commit bc920f6111
18 changed files with 398 additions and 172 deletions

View File

@@ -23,6 +23,7 @@
}, },
"registries": { "registries": {
"@skiper-ui": "https://skiper-ui.com/registry/{name}.json", "@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"
} }
} }

View File

@@ -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 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. `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. Yerleşim
### 5.1 Genel ### 5.1 Genel

View File

@@ -31,7 +31,6 @@
"@ai-sdk/react": "^4.0.130", "@ai-sdk/react": "^4.0.130",
"@cds/city": "^1.1.0", "@cds/city": "^1.1.0",
"@libsql/client": "^0.17.4", "@libsql/client": "^0.17.4",
"@number-flow/react": "^0.6.2",
"@shadcn/helpers": "^0.2.0", "@shadcn/helpers": "^0.2.0",
"ai": "^7.0.127", "ai": "^7.0.127",
"better-auth": "^1.6.23", "better-auth": "^1.6.23",

28
pnpm-lock.yaml generated
View File

@@ -20,9 +20,6 @@ importers:
'@libsql/client': '@libsql/client':
specifier: ^0.17.4 specifier: ^0.17.4
version: 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': '@shadcn/helpers':
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0(ai@7.0.127(zod@4.4.3)) version: 0.2.0(ai@7.0.127(zod@4.4.3))
@@ -1289,12 +1286,6 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} 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': '@opentelemetry/semantic-conventions@1.43.0':
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -3547,9 +3538,6 @@ packages:
jiti: jiti:
optional: true optional: true
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
espree@10.4.0: espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4692,9 +4680,6 @@ packages:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'} engines: {node: '>=18'}
number-flow@0.6.2:
resolution: {integrity: sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==}
oauth-sign@0.9.0: oauth-sign@0.9.0:
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
@@ -6693,13 +6678,6 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@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': {} '@opentelemetry/semantic-conventions@1.43.0': {}
'@postman/form-data@3.1.1': '@postman/form-data@3.1.1':
@@ -9109,8 +9087,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
esm-env@1.2.2: {}
espree@10.4.0: espree@10.4.0:
dependencies: dependencies:
acorn: 8.17.0 acorn: 8.17.0
@@ -10435,10 +10411,6 @@ snapshots:
path-key: 4.0.0 path-key: 4.0.0
unicorn-magic: 0.3.0 unicorn-magic: 0.3.0
number-flow@0.6.2:
dependencies:
esm-env: 1.2.2
oauth-sign@0.9.0: {} oauth-sign@0.9.0: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { startTransition, useEffect, useState } from "react"; import { startTransition, useEffect, useState } from "react";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { CalendarDays, Clock3 } from "lucide-react"; import { CalendarDays, Clock3 } from "lucide-react";
import { import {
BANT_ARIA, BANT_ARIA,
@@ -131,34 +131,18 @@ export function SiteTopBanner() {
: "Geri sayım yükleniyor" : "Geri sayım yükleniyor"
} }
> >
<NumberFlowGroup> {/* 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 }) => ( {BIRIMLER.map(({ anahtar, etiket }) => (
<div className="flex items-baseline gap-0.5" key={anahtar}> <div className="flex items-baseline gap-0.5" key={anahtar}>
<dt className="sr-only">{etiket}</dt> <dt className="sr-only">{etiket}</dt>
<dd className="font-semibold"> <dd className="font-semibold">
{geriSayim ? ( {geriSayim ? (
<NumberFlow <SayiRulo
value={geriSayim[anahtar]} deger={geriSayim[anahtar]}
trend={-1} enAzBasamak={2}
format={{ yon={-1}
minimumIntegerDigits: 2, className="min-w-[2ch] justify-end"
useGrouping: false,
}}
transformTiming={{
duration: 280,
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
spinTiming={{
duration: 280,
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
opacityTiming={{
duration: 180,
easing: "ease-out",
}}
willChange={anahtar === "saniye"}
className="inline-block min-w-[2ch] text-right"
aria-hidden
/> />
) : ( ) : (
"--" "--"
@@ -169,7 +153,6 @@ export function SiteTopBanner() {
</dd> </dd>
</div> </div>
))} ))}
</NumberFlowGroup>
</dl> </dl>
) : null} ) : null}
</div> </div>

View File

@@ -3,20 +3,22 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Monitor, Moon, Sun } from "lucide-react"; import { Monitor, Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils"; import { SecimHaplari } from "@/components/ui/secim-haplari";
const SECENEKLER = [ const SECENEKLER = [
{ deger: "light", etiket: "Açık", ikon: Sun }, { deger: "light", etiket: "Açık", ikon: Sun },
{ deger: "dark", etiket: "Koyu", ikon: Moon }, { deger: "dark", etiket: "Koyu", ikon: Moon },
{ deger: "system", etiket: "Sistem", ikon: Monitor }, { deger: "system", etiket: "Sistem", ikon: Monitor },
] as const; ] as const;
type Tema = (typeof SECENEKLER)[number]["deger"];
const aboneOlma = () => () => {}; const aboneOlma = () => () => {};
/** /**
* Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca * 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, * 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 }) { export function TemaSecici({ className }: { className?: string }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -25,37 +27,19 @@ export function TemaSecici({ className }: { className?: string }) {
() => true, () => true,
() => false, () => false,
); );
const secili = tarayicida
? (SECENEKLER.find((s) => s.deger === theme)?.deger ?? null)
: null;
return ( return (
<div <SecimHaplari<Tema>
role="radiogroup" ariaLabel="Tema"
aria-label="Tema" secenekler={SECENEKLER}
className={cn( deger={secili}
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1", onChange={setTheme}
className, varyant="kapsul"
)} boyut="md"
> className={className}
{SECENEKLER.map(({ deger, etiket, ikon: Ikon }) => { />
const secili = tarayicida && theme === deger;
return (
<button
key={deger}
type="button"
role="radio"
aria-checked={secili}
onClick={() => setTheme(deger)}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-sm font-semibold transition-[background-color,color,transform] duration-150 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
secili
? "bg-slate-900 text-slate-50"
: "text-slate-600 hover:text-orange-500",
)}
>
<Ikon className="size-4" aria-hidden />
{etiket}
</button>
);
})}
</div>
); );
} }

View File

@@ -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<number, Intl.NumberFormat>();
/** 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<number>; 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 (
<motion.span
className="absolute inset-0 flex items-center justify-center"
style={{ y, opacity, visibility }}
>
{rakam}
</motion.span>
);
}
/** 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 (
<>
<span className="invisible">0</span>
{RAKAMLAR.map((r) => (
<Glif key={r} konum={konum} rakam={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 <span className={className}>{metin}</span>;
return (
<span className={cn("inline-flex", className)}>
<span className="sr-only">{metin}</span>
<span aria-hidden className="inline-flex">
<AnimatePresence initial={false} custom={yon}>
{parcala(deger, bicim).map((p) => (
<motion.span
key={p.anahtar}
custom={yon}
variants={yuva}
initial="giris"
animate="merkez"
exit="cikis"
className="relative inline-block overflow-hidden"
>
{"rakam" in p ? <Tekerlek rakam={p.rakam} yon={yon} /> : p.metin}
</motion.span>
))}
</AnimatePresence>
</span>
</span>
);
}

View File

@@ -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<T extends string> {
deger: T;
etiket: string;
ikon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
}
export function SecimHaplari<T extends string>({
secenekler,
deger,
onChange,
ariaLabel,
varyant = "ayrik",
boyut = "sm",
className,
}: {
secenekler: readonly SecimHapiSecenek<T>[];
/** `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 (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn(
"flex flex-wrap",
kapsul
? "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1"
: "gap-1.5",
className,
)}
>
{secenekler.map(({ deger: d, etiket, ikon: Ikon }) => {
const secili = deger === d;
return (
<button
key={d}
type="button"
role="radio"
aria-checked={secili}
onClick={() => onChange(d)}
className={cn(
"relative inline-flex cursor-pointer items-center gap-1.5 rounded-full font-semibold outline-none transition-[color,border-color,background-color,transform] duration-200 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
yukseklik,
kapsul
? secili
? "text-slate-50"
: "text-slate-600 hover:text-orange-500"
: secili
? "border border-transparent text-white"
: "border border-slate-200 bg-card text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
>
{secili ? (
<motion.span
layoutId={`${uid}-vurgu`}
initial={false}
transition={azalt ? { duration: 0 } : YAY}
className={cn(
"absolute inset-0 rounded-full",
kapsul ? "bg-slate-900" : "bg-primary",
)}
aria-hidden
/>
) : null}
{Ikon ? (
<Ikon className="relative size-4 shrink-0" aria-hidden />
) : null}
<span className="relative">{etiket}</span>
</button>
);
})}
</div>
);
}

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
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 "sonner";
@@ -129,11 +130,15 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
disabled={busy !== null || bekleme > 0} 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" className="cursor-pointer font-medium text-primary underline-offset-2 hover:underline disabled:cursor-default disabled:text-slate-400 disabled:no-underline"
> >
{busy === "email" {busy === "email" ? (
? "Gönderiliyor…" "Gönderiliyor…"
: bekleme > 0 ) : bekleme > 0 ? (
? `Yeniden gönder (${bekleme})` <>
: "Yeniden gönder"} Yeniden gönder (<SayiRulo deger={bekleme} />)
</>
) : (
"Yeniden gönder"
)}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { ClipboardList, Coins } from "lucide-react"; import { ClipboardList, Coins } from "lucide-react";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { ListemButonu } from "@/features/liste/components/listem-butonu"; import { ListemButonu } from "@/features/liste/components/listem-butonu";
@@ -69,7 +70,9 @@ export async function UserNav() {
title="Kredilerin — yüklemek için tıkla" title="Kredilerin — yüklemek için tıkla"
> >
<Coins className="size-3.5 text-amber-500 sm:size-4" aria-hidden /> <Coins className="size-3.5 text-amber-500 sm:size-4" aria-hidden />
<span className="tabular-nums">{u.creditBalance}</span> <span className="tabular-nums">
<SayiRulo deger={u.creditBalance} />
</span>
{/* Etiket yalnızca md–lg arası: daha darda logo kesiliyor, lg üstünde {/* Etiket yalnızca md–lg arası: daha darda logo kesiliyor, lg üstünde
ortadaki nav ile yer dar (konteyner 1152'de sabit). İkon+sayı ve ortadaki nav ile yer dar (konteyner 1152'de sabit). İkon+sayı ve
title her genişlikte anlamı taşıyor. */} title her genişlikte anlamı taşıyor. */}

View File

@@ -6,6 +6,7 @@
// hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage). // hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage).
// Exit-intent/agresif popup YOK (PRD kuralı). // Exit-intent/agresif popup YOK (PRD kuralı).
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -72,7 +73,7 @@ export function KaydetBanner() {
Listen yalnızca bu tarayıcıda Listen yalnızca bu tarayıcıda
</p> </p>
<p className="text-sm leading-snug text-slate-500"> <p className="text-sm leading-snug text-slate-500">
Giriş yap; {liste.length} seçimin hesabında saklansın, her Giriş yap; <SayiRulo deger={liste.length} /> seçimin hesabında saklansın, her
cihazdan aç{" "} cihazdan aç{" "}
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" /> <CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
</p> </p>

View File

@@ -15,6 +15,7 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
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 { CtaBandi } from "@/components/cta-bandi"; import { CtaBandi } from "@/components/cta-bandi";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
import { RiskEtiketi } from "@/components/risk-etiketi"; import { RiskEtiketi } from "@/components/risk-etiketi";
@@ -198,7 +199,7 @@ export function ListeCekmecesi() {
</span> </span>
) : ( ) : (
<> <>
{liste.length} / {MANUEL_LISTE_MAX} tercih <SayiRulo deger={liste.length} /> / {MANUEL_LISTE_MAX} tercih
</> </>
)} )}
</p> </p>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { ArrowRight, Plus } from "lucide-react"; import { ArrowRight, Plus } from "lucide-react";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { import {
MANUEL_LISTE_MAX, MANUEL_LISTE_MAX,
cekmeceAc, cekmeceAc,
@@ -41,9 +42,17 @@ export function ListeDurumuSatiri() {
onClick={cekmeceAc} onClick={cekmeceAc}
className="group inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-sm 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]" className="group inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-sm 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]"
> >
Listende {liste.length}/{MANUEL_LISTE_MAX} program <span>
Listende <SayiRulo deger={liste.length} />/{MANUEL_LISTE_MAX} program
</span>
<span className="font-medium text-slate-500 group-hover:text-orange-500"> <span className="font-medium text-slate-500 group-hover:text-orange-500">
{kalan > 0 ? `· ${kalan} yer kaldı` : "· liste tamam"} {kalan > 0 ? (
<>
· <SayiRulo deger={kalan} /> yer kaldı
</>
) : (
"· liste tamam"
)}
</span> </span>
<ArrowRight <ArrowRight
className="size-4 transition-transform duration-150 group-hover:translate-x-0.5" className="size-4 transition-transform duration-150 group-hover:translate-x-0.5"

View File

@@ -5,7 +5,7 @@
// yaşadığı /listem sayfasına aittir — isim çakışmasın.) // yaşadığı /listem sayfasına aittir — isim çakışmasın.)
import { ListChecks } from "lucide-react"; import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react"; import { SayiRulo } from "@/components/ui/sayi-rulo";
import { import {
MANUEL_LISTE_MAX, MANUEL_LISTE_MAX,
cekmeceDegistir, cekmeceDegistir,
@@ -17,7 +17,6 @@ import { preloadListeCekmecesi } from "./liste-cekmecesi-lazy";
export function ListemButonu() { export function ListemButonu() {
const liste = useManuelListe(); const liste = useManuelListe();
const acik = useCekmeceAcik(); const acik = useCekmeceAcik();
const azMotion = useReducedMotion();
// Liste boşken buton gürültü: tabloya (+) ulaşmamış ziyaretçiye "0/24" // Liste boşken buton gürültü: tabloya (+) ulaşmamış ziyaretçiye "0/24"
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider. // bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
@@ -40,16 +39,11 @@ export function ListemButonu() {
aria-label anlamı taşıyor. UserNav'daki pill'le aynı desen. */} aria-label anlamı taşıyor. UserNav'daki pill'le aynı desen. */}
<span className="hidden">Kendi Listem</span> <span className="hidden">Kendi Listem</span>
{liste.length > 0 ? ( {liste.length > 0 ? (
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur // Rozet yerinde durur, rakam rulo gibi döner (eklemede yukarı,
<motion.span // çıkarmada aşağı) — Arc "Number field" rulosu, bkz. sayi-rulo.tsx.
key={liste.length} <span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-orange-500 px-1.5 font-heading text-xs font-bold text-white">
initial={azMotion ? false : { scale: 0.6 }} <SayiRulo deger={liste.length} />
animate={{ scale: 1 }} </span>
transition={{ type: "spring", duration: 0.4, bounce: 0.45 }}
className="flex h-6 min-w-6 items-center justify-center rounded-full bg-orange-500 px-1.5 font-heading text-xs font-bold text-white"
>
{liste.length}
</motion.span>
) : null} ) : null}
</button> </button>
); );

View File

@@ -22,6 +22,7 @@ import type {
} from "@/types/yokatlas"; } from "@/types/yokatlas";
import { DILIM_ETIKET, riskHesapla } from "@/lib/risk"; import { DILIM_ETIKET, riskHesapla } from "@/lib/risk";
import { bolumSayfaSlug, ilSayfaHref, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug"; import { bolumSayfaSlug, ilSayfaHref, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { TurCipi } from "@/components/tur-cipi"; import { TurCipi } from "@/components/tur-cipi";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { VERI_YIL_SAYISI } from "@/lib/veri-yillari"; import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
@@ -306,7 +307,7 @@ export function ProgramTablosu({
<ChevronDown className="size-4" aria-hidden /> <ChevronDown className="size-4" aria-hidden />
</Button> </Button>
<span className="text-sm tabular-nums text-slate-500"> <span className="text-sm tabular-nums text-slate-500">
{satirlar.length.toLocaleString("tr-TR")} /{" "} <SayiRulo deger={satirlar.length} /> /{" "}
{toplam.toLocaleString("tr-TR")} program {toplam.toLocaleString("tr-TR")} program
</span> </span>
</div> </div>

View File

@@ -19,6 +19,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { AracPilleri, aracPilleri } from "./arac-pilleri"; import { AracPilleri, aracPilleri } from "./arac-pilleri";
import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti"; import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti";
@@ -643,7 +644,7 @@ export function SohbetClient({
title="Kredilerin — yüklemek için tıkla" title="Kredilerin — yüklemek için tıkla"
> >
<Coins className="size-3.5" aria-hidden /> <Coins className="size-3.5" aria-hidden />
{kredi} kredi <SayiRulo deger={kredi} /> kredi
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight, Trophy, X } from "lucide-react"; import { ArrowRight, Trophy, X } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -37,6 +38,7 @@ const PUAN_TURLERI_SECENEK = [
{ deger: "dil", etiket: "Dil" }, { deger: "dil", etiket: "Dil" },
{ deger: "tyt", etiket: "TYT" }, { deger: "tyt", etiket: "TYT" },
] as const; ] as const;
type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"];
// Kayıtlı profilli CTA'lar /listem boş durumunun dilini taşır (bkz. // 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. // 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 [acik, setAcik] = useState(false);
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira"); const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
const [siralama, setSiralama] = useState(""); const [siralama, setSiralama] = useState("");
const [tur, setTur] = useState<string>("say"); const [tur, setTur] = useState<PuanTuru>("say");
const [sira, setSira] = useState<number | null>(null); const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null); const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false); const [yukleniyor, setYukleniyor] = useState(false);
@@ -236,28 +238,14 @@ export function CtaSiraForm({
: "flex flex-col gap-3" : "flex flex-col gap-3"
} }
> >
<div {/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */}
role="radiogroup" <SecimHaplari
aria-label="Puan türü" ariaLabel="Puan türü"
className={cn("flex flex-wrap gap-1.5", kompakt && "lg:shrink-0")} secenekler={PUAN_TURLERI_SECENEK}
> deger={tur}
{PUAN_TURLERI_SECENEK.map((t) => ( onChange={setTur}
<button className={cn(kompakt && "lg:shrink-0")}
key={t.deger} />
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-card px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<label htmlFor={alanId} className="sr-only"> <label htmlFor={alanId} className="sr-only">
YKS başarı sıralaması YKS başarı sıralaması

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -35,10 +36,11 @@ const PUAN_TURLERI_SECENEK = [
{ deger: "dil", etiket: "Dil" }, { deger: "dil", etiket: "Dil" },
{ deger: "tyt", etiket: "TYT" }, { deger: "tyt", etiket: "TYT" },
] as const; ] as const;
type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"];
export function HeroForm() { export function HeroForm() {
const [siralama, setSiralama] = useState(""); const [siralama, setSiralama] = useState("");
const [tur, setTur] = useState<string>("say"); const [tur, setTur] = useState<PuanTuru>("say");
const [sira, setSira] = useState<number | null>(null); const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null); const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false); const [yukleniyor, setYukleniyor] = useState(false);
@@ -140,28 +142,14 @@ export function HeroForm() {
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye // max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu). // (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
<div className="flex w-full max-w-xl flex-col items-center gap-3"> <div className="flex w-full max-w-xl flex-col items-center gap-3">
<div {/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */}
role="radiogroup" <SecimHaplari
aria-label="Puan türü" ariaLabel="Puan türü"
className="flex flex-wrap justify-center gap-1.5" secenekler={PUAN_TURLERI_SECENEK}
> deger={tur}
{PUAN_TURLERI_SECENEK.map((t) => ( onChange={setTur}
<button className="justify-center"
key={t.deger} />
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-card px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}