Compare commits

..

1 Commits

Author SHA1 Message Date
bilalgursen
bc920f6111 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>
2026-10-06 19:40:30 +03:00
22 changed files with 400 additions and 245 deletions

View File

@@ -16,27 +16,6 @@ Açtığında geçerli olanlar:
- Tarayıcıda hesap açma, şifre/kart/kimlik girme, ödeme, mesaj gönderme, yayın/post, CAPTCHA çözme, dosya indirme **yine yasak** (PROTOKOL md.5). Çerez bandı çıkarsa reddet. Kullanıcının açık oturumlarına girme.
- Canlı siteye (`kolaytercih.com`) bakmak serbesttir ve çoğu zaman dev sunucusu açmaktan ucuzdur — önce onu dene.
# Sürümleme (7 Eki 2026'dan itibaren geçerli)
Site semantik sürüm taşır; tek kaynak `package.json` `version` alanıdır, header'daki
rozet (`src/components/surum-rozeti.tsx`, `src/lib/surum.ts`) bunu build'den okur.
Canlıdaki (`main`) sistem **1.0.0 Beta**'dır; `v2` dalı **2.0.0** geliştirmesidir.
Kurallar:
- **Major (X.0.0):** sihirbaz/liste/ödeme akışında kullanıcının gördüğü davranış değişir,
eski liste ya da raporlar yeni sistemle uyumsuz hâle gelir, ya da veri şeması kırılır.
Yalnızca Bilal açar; `vN` adlı dalda biriktirilir, `main`'e merge edilince `package.json`
`N.0.0` olur.
- **Minor (x.Y.0):** yeni özellik, yeni ekran, yeni rehber altyapısı — geriye uyumlu.
Özelliği `main`'e alan commit `package.json`'daki sürümü de artırır (aynı commit).
- **Patch (x.y.Z):** hata düzeltmesi, metin/stil düzeltmesi, içerik. Düzeltme commit'i
artırır; yalnız rehber yazısı ekleyen commit'ler sürüm artırmaz.
- "Beta" etiketi rozette sabittir; kalkması Bilal'in kararıdır (rozet metni tek yerde,
`surum-rozeti.tsx`).
- Sürüm artıran commit mesajı `chore(surum): 1.1.0` ya da özelliğin mesajına `(→ 1.1.0)`
ekiyle yazılır; `main`'e giden her push'tan sonra canlıdaki rozetin beklenen sayıyı
gösterdiği kontrol edilir.
# Push öncesi build zorunlu
Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add <dir> HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et.
@@ -51,7 +30,7 @@ Sitede açık / koyu / sistem teması var (DESIGN.md §2.3). Karanlık mod `src/
# Badge/eyebrow rozetleri: tek kaynak SectionEyebrow
Site genelindeki tüm badge/rozet/eyebrow elemanları ana sayfadaki section başlıklarının üstünde duran rozetle birebir aynı olmalı. Kanonik bileşen `src/components/pixel-decor.tsx` içindeki `SectionEyebrow`dur (piksel imzalı `PixelMark` ikonu + `inline-flex items-center gap-2 rounded-full border border-slate-200 bg-card px-3.5 py-1.5 text-xs font-semibold text-primary`; metin büyük-küçük harfli yazılır — uppercase yok). Yeni bir badge/eyebrow gerektiğinde `SectionEyebrow` kullan (`className` prop'u kenar boşluğu için var); `@/components/ui/badge`'deki `Badge` ile ya da elle yazılmış pill span'lerle yeni badge tasarlama. İnteraktif chip/buton pill'leri (filtre çipleri, nav butonları) bu kuralın dışındadır. Header'daki "Beta" sürüm rozeti (`src/components/surum-rozeti.tsx`) de dışındadır: o Arc UI Badge biçiminde, akademik gösterge morunda (`bg-indigo-500`) tek bir yerde durur; başka yere kopyalanmaz.
Site genelindeki tüm badge/rozet/eyebrow elemanları ana sayfadaki section başlıklarının üstünde duran rozetle birebir aynı olmalı. Kanonik bileşen `src/components/pixel-decor.tsx` içindeki `SectionEyebrow`dur (piksel imzalı `PixelMark` ikonu + `inline-flex items-center gap-2 rounded-full border border-slate-200 bg-card px-3.5 py-1.5 text-xs font-semibold text-primary`; metin büyük-küçük harfli yazılır — uppercase yok). Yeni bir badge/eyebrow gerektiğinde `SectionEyebrow` kullan (`className` prop'u kenar boşluğu için var); `@/components/ui/badge`'deki `Badge` ile ya da elle yazılmış pill span'lerle yeni badge tasarlama. İnteraktif chip/buton pill'leri (filtre çipleri, nav butonları) bu kuralın dışındadır.
# UI tutarlılığı: ortak elemanlar tek kaynaktan

View File

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

View File

@@ -114,8 +114,6 @@ turuncu **tek tondur: `orange-500`** — CTA butonunun turuncusuyla birebir ayn
ton/opaklıkta bu renkler yine yasak. (`bg-violet-50 text-violet-600` tek bir mevcut durum
çipidir; yeni yere taşıma — üniversite sayfasında "Vakıf" çipi bu yüzden hap + ikonla
değiştirildi, §4.12.)
İkinci istisna: üst çubuktaki "Beta" sürüm rozeti, dolu `bg-indigo-500` (§4.14, Bilal'in
kararı, 7 Eki 2026) — gösterge karesiyle aynı mor, yine yalnız dolu zemin.
- Gradyan metin (`bg-clip-text text-transparent`) ve parlama gölgesi (`shadow-[0_0_…]`) yok.
- Hex/oklch değeri sınıf içine yazılmaz (`text-[#…]`); token ya da Tailwind tonu kullanılır.
Risk renklerinin SVG karşılığı `src/lib/risk.ts` içindeki `RISK_PIN_RENK`'ten okunur.
@@ -450,16 +448,6 @@ verilir (logo listesi istemciye inmez).
Hareket: yay `bounce: 0, duration: 0.35`, giriş ve çıkış aynı yoldan; "hareketi azalt"ta yalnız
opaklık.
**Sürüm rozeti** (`src/components/surum-rozeti.tsx`, 7 Eki 2026): logo yazısının hemen
sağında "Beta" hapı + sürüm numarası. Hap Arc UI Badge (uiarc.dev/components/badge) `sm`
boyunun karşılığı (yazı §3 alt sınırı gereği 11 yerine 12 px): `inline-flex h-[22px] items-center rounded-full bg-indigo-500 px-2
text-xs leading-none font-semibold tracking-[-0.01em] text-white` — renk akademik gösterge
karesinin moru (§2.2 istisnası), renkli zeminde yazı `font-semibold`. Sürüm hapın **içinde
değil**, yanında: `text-xs font-medium text-slate-500 tabular-nums`, `hidden sm:inline`
(dar ekranda yalnız hap kalır). Rozet logo yazısıyla aynı fade sınıflarını taşır; NavBaslik
yuvası açılınca birlikte söner. Sayı `package.json`'dan gelir (AGENTS.md "Sürümleme");
elle yazılmaz. Başka yerde tekrarlanmaz — SectionEyebrow kuralının tek istisnasıdır.
### 4.15 Liste satırı (program/üniversite listeleri) — `src/components/liste-satiri.ts`
Sınıf dizileri tek dosyada (`SATIR_AD`, `SATIR_META`, `SATIR_CIP`, `SATIR_MOBIL_SAYI`,
`SATIR_BUTON_KABI`…); üniversite, bölüm ve `/sonuc` tabloları oradan okur. Yeni bir liste
@@ -698,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

View File

@@ -1,6 +1,6 @@
{
"name": "kolaytercih",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -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",

28
pnpm-lock.yaml generated
View File

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

View File

@@ -1,7 +1,6 @@
import { Suspense } from "react";
import Link from "next/link";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { SurumRozeti } from "@/components/surum-rozeti";
import { KatalogArama } from "@/features/katalog/components/katalog-arama";
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
import { getRehberAramaKayitlari } from "@/lib/rehber";
@@ -33,9 +32,6 @@ export function SiteHeader() {
Tercih
</span>
</span>
{/* Beta rozeti + sürüm (surum-rozeti.tsx); yazı logosuyla aynı
fade'i taşır ki NavBaslik yuvası açılınca birlikte sönsün. */}
<SurumRozeti className="ml-2 transition-[opacity,translate] duration-300 ease-out in-data-[nav-baslik]:pointer-events-none in-data-[nav-baslik]:-translate-y-2 in-data-[nav-baslik]:opacity-0 motion-reduce:transition-opacity sm:ml-3" />
</Link>
<div
id="nav-baslik-yuvasi"

View File

@@ -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"
}
>
<NumberFlowGroup>
{BIRIMLER.map(({ anahtar, etiket }) => (
<div className="flex items-baseline gap-0.5" key={anahtar}>
<dt className="sr-only">{etiket}</dt>
<dd className="font-semibold">
{geriSayim ? (
<NumberFlow
value={geriSayim[anahtar]}
trend={-1}
format={{
minimumIntegerDigits: 2,
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
/>
) : (
"--"
)}
<span className="ml-0.5 text-xs font-medium opacity-70">
{etiket}
</span>
</dd>
</div>
))}
</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 }) => (
<div className="flex items-baseline gap-0.5" key={anahtar}>
<dt className="sr-only">{etiket}</dt>
<dd className="font-semibold">
{geriSayim ? (
<SayiRulo
deger={geriSayim[anahtar]}
enAzBasamak={2}
yon={-1}
className="min-w-[2ch] justify-end"
/>
) : (
"--"
)}
<span className="ml-0.5 text-xs font-medium opacity-70">
{etiket}
</span>
</dd>
</div>
))}
</dl>
) : null}
</div>

View File

@@ -1,26 +0,0 @@
import { SURUM } from "@/lib/surum";
/**
* Header'da logonun yanındaki "Beta" rozeti + sürüm numarası (DESIGN.md
* §4.21). Rozet Arc UI Badge (uiarc.dev/components/badge, `sm` boyu: 22px,
* px-2, pill; yazı §3 gereği 12 px) biçiminde; rengi akademik gösterge karesinin moru
* (`bg-indigo-500`, §2.2 istisnası). Sürüm rozetin içinde değil, yanında
* küçük puntoyla; dar ekranda yalnız rozet kalır. Logo fade'iyle birlikte
* söner (NavBaslik yuvası açılınca), o yüzden site-header'daki fade
* sınıflarını aynen taşır.
*/
export function SurumRozeti({ className = "" }: { className?: string }) {
return (
<span className={`inline-flex shrink-0 items-center gap-1.5 ${className}`}>
<span className="inline-flex h-[22px] items-center rounded-full bg-indigo-500 px-2 text-xs leading-none font-semibold tracking-[-0.01em] whitespace-nowrap text-white">
Beta
</span>
<span
className="hidden text-xs leading-none font-medium tracking-[-0.01em] text-slate-500 tabular-nums sm:inline"
aria-label={`Sürüm ${SURUM}`}
>
v{SURUM}
</span>
</span>
);
}

View File

@@ -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 (
<div
role="radiogroup"
aria-label="Tema"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1",
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>
<SecimHaplari<Tema>
ariaLabel="Tema"
secenekler={SECENEKLER}
deger={secili}
onChange={setTheme}
varyant="kapsul"
boyut="md"
className={className}
/>
);
}

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

View File

@@ -1,8 +0,0 @@
import pkg from "../../package.json";
/**
* Sitenin sürümü — tek kaynak package.json `version` alanıdır (AGENTS.md
* "Sürümleme"). Header'daki rozet bunu okur; build'e girdiği için canlıda
* görünen sayı her zaman deploy edilen commit'in package.json'ıdır.
*/
export const SURUM: string = pkg.version;