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

@@ -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 (<SayiRulo deger={bekleme} />)
</>
) : (
"Yeniden gönder"
)}
</button>
<button
type="button"

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { ClipboardList, Coins } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { ListemButonu } from "@/features/liste/components/listem-butonu";
@@ -69,7 +70,9 @@ export async function UserNav() {
title="Kredilerin — yüklemek için tıkla"
>
<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
ortadaki nav ile yer dar (konteyner 1152'de sabit). İkon+sayı ve
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).
// Exit-intent/agresif popup YOK (PRD kuralı).
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -72,7 +73,7 @@ export function KaydetBanner() {
Listen yalnızca bu tarayıcıda
</p>
<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ç{" "}
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
</p>

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { ArrowRight, Plus } from "lucide-react";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
@@ -41,9 +42,17 @@ export function ListeDurumuSatiri() {
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]"
>
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">
{kalan > 0 ? `· ${kalan} yer kaldı` : "· liste tamam"}
{kalan > 0 ? (
<>
· <SayiRulo deger={kalan} /> yer kaldı
</>
) : (
"· liste tamam"
)}
</span>
<ArrowRight
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.)
import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import {
MANUEL_LISTE_MAX,
cekmeceDegistir,
@@ -17,7 +17,6 @@ import { preloadListeCekmecesi } from "./liste-cekmecesi-lazy";
export function ListemButonu() {
const liste = useManuelListe();
const acik = useCekmeceAcik();
const azMotion = useReducedMotion();
// 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.
@@ -40,16 +39,11 @@ export function ListemButonu() {
aria-label anlamı taşıyor. UserNav'daki pill'le aynı desen. */}
<span className="hidden">Kendi Listem</span>
{liste.length > 0 ? (
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
<motion.span
key={liste.length}
initial={azMotion ? false : { scale: 0.6 }}
animate={{ scale: 1 }}
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>
// Rozet yerinde durur, rakam rulo gibi döner (eklemede yukarı,
// çıkarmada aşağı) — Arc "Number field" rulosu, bkz. sayi-rulo.tsx.
<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">
<SayiRulo deger={liste.length} />
</span>
) : null}
</button>
);

View File

@@ -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({
<ChevronDown className="size-4" aria-hidden />
</Button>
<span className="text-sm tabular-nums text-slate-500">
{satirlar.length.toLocaleString("tr-TR")} /{" "}
<SayiRulo deger={satirlar.length} /> /{" "}
{toplam.toLocaleString("tr-TR")} program
</span>
</div>

View File

@@ -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"
>
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
<SayiRulo deger={kredi} /> kredi
</Link>
</div>
</div>

View File

@@ -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<string>("say");
const [tur, setTur] = useState<PuanTuru>("say");
const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
@@ -236,28 +238,14 @@ export function CtaSiraForm({
: "flex flex-col gap-3"
}
>
<div
role="radiogroup"
aria-label="Puan türü"
className={cn("flex flex-wrap gap-1.5", kompakt && "lg:shrink-0")}
>
{PUAN_TURLERI_SECENEK.map((t) => (
<button
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>
{/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */}
<SecimHaplari
ariaLabel="Puan türü"
secenekler={PUAN_TURLERI_SECENEK}
deger={tur}
onChange={setTur}
className={cn(kompakt && "lg:shrink-0")}
/>
<label htmlFor={alanId} className="sr-only">
YKS başarı sıralaması

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SecimHaplari } from "@/components/ui/secim-haplari";
import {
Dialog,
DialogContent,
@@ -35,10 +36,11 @@ const PUAN_TURLERI_SECENEK = [
{ deger: "dil", etiket: "Dil" },
{ deger: "tyt", etiket: "TYT" },
] as const;
type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"];
export function HeroForm() {
const [siralama, setSiralama] = useState("");
const [tur, setTur] = useState<string>("say");
const [tur, setTur] = useState<PuanTuru>("say");
const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
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
// (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
role="radiogroup"
aria-label="Puan türü"
className="flex flex-wrap justify-center gap-1.5"
>
{PUAN_TURLERI_SECENEK.map((t) => (
<button
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>
{/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */}
<SecimHaplari
ariaLabel="Puan türü"
secenekler={PUAN_TURLERI_SECENEK}
deger={tur}
onChange={setTur}
className="justify-center"
/>
<form
onSubmit={handleSubmit}