merge(0924): bölüm sayfası ve öğrenci-samimi dalları — şişko ve sade dil tüm ekranlarda, samimi ton (DESIGN.md §10), funnel CTA piksel liste
All checks were successful
Deploy / deploy (push) Successful in 4m36s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-24 10:20:40 +03:00
52 changed files with 2295 additions and 919 deletions

View File

@@ -0,0 +1,106 @@
---
name: better-ui
description: Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, image outlines, box shadows.
---
> **KolayTercih notu (25 Eyl 2026, Bilal'in isteğiyle eklendi).** Kaynak: Jakub Krehel,
> https://github.com/jakubkrehel/skills (ui-skills.com üzerinden okundu, elle kopyalandı).
> Bu projede `docs/tasarim/DESIGN.md` önceliklidir. Çakışan iki nokta:
> basınca küçülme bizde `scale-[0.97]` (geniş satır `0.99`, çarpı `0.96`), buradaki `0.96`
> değil; kart ve satırlarda yapıyı `border-slate-200` verir, gölge yalnız §4.4'teki kadar.
> Görsel ayar yaparken önce DESIGN.md, sonra bu dosya.
# UI polish
Polish comes from a pile of small details that compound. This skill is the reference for which are worth having and what values they take.
When reviewing, slow the interface down. What feels off at 10% speed is what is subtly wrong at full speed.
Keep the project's component library, tokens and density, and match its motion language except where a rule below prescribes an exact interaction.
Every duration, curve, scale and blur below is a specific value, not a range to approximate. cubic-bezier(0.2, 0, 0, 1) is not cubic-bezier(0.4, 0, 0.2, 1), and 0.96 is not 0.95. Use what is written.
## Concentric border radius
Outer radius = inner radius + padding. Mismatched radii on nested elements is the most common thing that makes an interface feel off.
## Optical over geometric alignment
When geometric centering looks off, align optically. Buttons with icons, play triangles and asymmetric icons all need a manual nudge.
## Shadows for elevation, borders for structure
Where a border exists only to create depth, prefer layered transparent box-shadow values. Keep borders that communicate structure or state: dividers, separators and selected or focus states.
## Interruptible animations
Use CSS transitions for interactive state changes, because they can be interrupted mid-animation. Reserve keyframes for staged sequences that run once.
## Split and stagger enter animations
For an infrequent staged entrance where sequence communicates hierarchy, break the content into semantic chunks and stagger them by ~100ms. Animating one container gets you less for the same cost. Leave high-frequency interactions unstaggered.
## Subtle exit animations
Use a small fixed translateY rather than full height. Exits should be softer than enters. Use ease-out for both directions.
## Contextual icon animations
Animate icons with opacity, scale and blur rather than toggling visibility. Use exactly these values: scale 0.25 to 1, opacity 0 to 1, blur 4px to 0px.
With a motion library (motion or framer-motion in package.json), match that package's import path. Use transition: { type: "spring", duration: 0.3, bounce: 0 }. Bounce is always 0.
Without one, keep both icons in the DOM with one absolutely positioned, and cross-fade with cubic-bezier(0.2, 0, 0, 1).
## Image outlines
Give images a 1px outline at low opacity for consistent depth. Pure black in light mode (oklch(0 0 0 / 0.1)), pure white in dark (oklch(1 0 0 / 0.1)). Never a near-black like slate or zinc and never a tinted neutral.
## Scale on press
A scale on click gives a button tactile feedback (upstream value 0.96; this project uses 0.97, see note above). Add a static prop to switch it off where motion would distract.
## Skip animation on page load
Use initial={false} on AnimatePresence to keep enter animations off the first render. Check that it leaves intentional page entrances intact.
## Suppress transitions on theme switch
A theme flip changes color, background, border and shadow on nearly every element at once. Inject *,*::before,*::after{transition:none !important}, force a reflow, then remove it on the next frame.
## Transition only what changes
Always name the exact properties: transition-property: scale, opacity. Tailwind's transition-transform covers transform, translate, scale, rotate.
## Use will-change sparingly
Only for transform, opacity and filter. Never will-change: all. Add it when you see first-frame stutter, not before.
## Match icon stroke to text weight
1.5px stroke beside regular (400) text, 2px beside semibold (600). One stroke weight per icon set and one icon library per surface.
## One SVG, recolored per state
Icons use currentColor and take hover, selected and disabled states from CSS color and opacity. Outline is the default variant; fill marks the active state.
## Motion restraint
Give high-frequency interactions instant feedback, or a transition of 150ms or less on opacity and color. Every animated state change also needs a static cue: color, an icon, or a label. Motion is never the only feedback channel.
## Before you finish
| Mistake | Fix |
|---|---|
| Icons look off-center | Nudge optically with padding, or fix the SVG |
| Jarring staged entrance or exit | Stagger infrequent entrances; keep exits subtle |
| Theme toggle crossfades the whole page | Disable transitions for the swap, force a reflow, restore on the next frame |
| transition: all on elements | Specify exact properties |
| First-frame animation stutter | Add will-change: transform (sparingly) |
| Hairline icon beside bold text | Match the stroke width to the text weight |
## Reporting
Severity. HIGH breaks an interaction, makes motion unusable, or leaves a state change visible only while the animation runs. MEDIUM is a visible inconsistency in surfaces, icons, or motion. LOW is isolated polish.
Format: group findings under the principle each violates, ordered by severity, one row per root cause: Severity | Location | Before | After | Why. End with Block when any HIGH remains, Approve otherwise. Report every check you could not run as Not verified.

View File

@@ -0,0 +1,146 @@
# Samimi ve oyunsu arayüz: araştırma ve öneriler
> 25 Eylül 2026. Bilal'in isteği: "Duolingo gibi basit ve oyunsu, Apple gibi irdeleyici gözle
> kurulmuş, samimi bir arayüz." Kaynaklar sonda. Uygulaması `tasarim/ogrenci-samimi` dalında;
> kurallar `DESIGN.md` §10'a işlendi.
## Tek cümle
Bizim öğrencimiz için "samimi" neşe değil, **güven** demek: kaygılı öğrenciye her ekranda
nerede olduğunu, sıradaki tek adımı ve kendi yaptığı küçük ilerlemeyi göstermek. Duolingo'nun
ilerleme ve küçük kutlama dilini alıyoruz. Seri, puan ve kayıp korkusunu almıyoruz.
## Neden böyle: öğrencimiz kim?
Aynı klasördeki öğrenci senaryoları (`docs/arastirma/2026-09-24-ogrenci-senaryolari/`) üç
duyguyu tekrar tekrar gösteriyor:
- **Yanlış yapma korkusu.** Can, geçen yılın tabanına güvenip listeyi yanlış kurmaktan korkuyor.
- **Mahcubiyet.** Ayşe Hanım "sistemi bilmiyorum" demekten, Yusuf "ailem para gönderemiyor"
demekten çekiniyor.
- **Dağınık bilgi.** Bilgi var ama dağınık ve zamansız. Kimse "şimdi şunu yap" demiyor.
Duolingo günlük bir alışkanlık ürünü. Tercih ise birkaç haftalık, tek seferlik ve yüksek riskli
bir karar. Bu yüzden oyunlaştırmanın yalnız bir kısmı bize uyuyor.
## Duolingo'dan alınacaklar ve alınmayacaklar
| Alıyoruz | Almıyoruz, çünkü |
|---|---|
| **Görünür ilerleme.** "Listen 7/24", "Adım 2/3: Şehir" | **Seri (streak).** Günlük alışkanlık için. Tercih günlük iş değil. |
| **Küçük kilometre taşı kutlaması.** İlk program eklendi, liste 24/24 doldu. Seri ekranındaki kutlama animasyonu yeni kullanıcının 7. günde kalma oranını %1,7 artırmış. | **Kayıp korkusu.** Duolingo uzun seride bunu bilerek kullanıyor, ama kırılınca motivasyonun çöktüğünü kendisi yazıyor. Kaygılı öğrenciye uygun değil. |
| **Başta küçük adımı büyük hissettirmek.** 2'den 3'e çıkan seri %50 büyür. Bizde ilk eklenen program. | **Puan, rozet koleksiyonu, liderlik tablosu.** Tek seferlik kararda toplanacak bir şey yok. Başkasıyla yarış kaygıyı artırır. |
| **Beyaz boşlukla ayırmak, kutuyla değil.** Duolingo 2026 sekme yenilemesi. | **Karakter ve maskot.** Yeni çizim işi. Piksel imzamız zaten bizim karakterimiz. |
| **Tutarlılık amaca hizmet eder.** Her başlığa süs koymak tutarlı ama zorlama. | **Şaka ve "Hay aksi!" tonu.** Apple: samimiyetsiz duruyor. Kaygılı kullanıcıya hiç. |
## Apple'dan alınacaklar (irdeleyici göz)
- **Sesimiz sabit, tonumuz duruma göre.** Liste tamamlanınca hafif bir tebrik. Hata ya da
ödemede düz ve ciddi. Açıkta kalan öğrenciye kutlama yok. NN/g'nin deyişiyle "neşe
dayatılamaz".
- **Ekranın amacı ilk satırda.** En önemli bilgi önce. Birden çok fikir varsa ekranı böl.
- **Düğmede fiil.** "Listemi aç", "Sıralamamı gir". "Hadi bakalım!" gibi şirin etiket yok.
Çok adımlı akışta tutarlı dil: "Devam" → "Devam" → son adımda ne olacağını söyleyen fiil.
- **"Biz" az.** Hata mesajında "biz" yok. "Bir sorun oluştu" değil, ne olduğu ve ne
yapılacağı yazılır.
- **Boş ekranda net sonraki adım** ve tek düğme.
- **Geri bildirim önemiyle orantılı.** Sıradan eklemede sessiz onay (düğme ✓ olur, sayaç
artar). Önemli anda (ilk program, liste doldu) görünür onay. Uyarı penceresi yalnız geri
dönüşsüz kayıpta.
- **Dürüst ilerleme.** Sahte yüzde yok. Beklerken ne yapıldığı kısa ve doğru yazılır.
- **İpucu yerinde.** Baştan eğitim turu yerine, öğrenci bir şeye ilk dokunduğunda.
## İlkeler
1. **Her ekranın tek cümlelik cevabı var.** `/sonuc`'a gelen öğrenci önce kendi sonucunu
görür: "85.000. sıradasın · Sayısal. 4.760 program açık."
2. **Öğrenci nerede olduğunu her ekranda görür.** Liste durumu sayıyla ("Listende 5/24
program · 19 yer kaldı"), sihirbazda adın kendisi ("Şehir"). Hedef eğimi ve Zeigarnik
etkisi: yarım işi tamamlama isteği. (Üç adımlı bir "tercih yolu" şeridi de düşünüldü;
ücretsiz liste ile yapay zekâ listesi iki ayrı yol olduğu için tek şeride sığdırmak
yanıltıcı olurdu, yapılmadı.)
3. **İlerleme dürüst ve somut.** Sayı ile yazılır ("7/24"), yüzde ya da sahte hız yok.
4. **Kutlama küçük, yerinde ve hak edilmiş.** Yalnız öğrencinin kendi başardığı adımda.
Yerleşme gibi sonuçlarda kutlama yok. "Hareketi azalt" açıksa animasyon yok, metin var.
5. **Metin sen diliyle, sakin, vaatsiz.** Fiil düğmeler. Jargon ilk geçtiği yerde açıklanır.
Hata mesajında suçlama ve ünlem yok.
6. **Boş durum = ne olduğu + tek düğme.**
7. **Anlamsal yapı herkes için.** Sayfa başına tek `h1`, bölümler `h2`. Odak almayan durum
değişiklikleri (`Listene eklendi`, `Adım 2/3`) ekran okuyucuya duyurulur (WCAG 4.1.3).
Açılır satırda `aria-expanded` ve `aria-controls` (APG). Dokunma hedefi 44 px (WCAG 2.5.5).
**Yapmadıklarımız (aldatıcı desenler):** sahte aciliyet ve geri sayım baskısı, sahte kıtlık,
utandırarak onay ("Hayır, geleceğimi umursamıyorum"), ısrarlı açılır pencere. "Kredi" dilinin
TL karşılığı açık bir karar olarak Bilal'e bırakıldı (aşağıda).
## Ekran ekran öneriler (öğrencinin kullandığı yerler)
| Ekran | Bugün | Öneri |
|---|---|---|
| `/sonuc` | Sayfada `h1` yok. İlk ekran yapay zekâ örneği, öğrencinin kendi sonucu değil. | Üstte sonuç başlığı: `h1` + sıra + açık program sayısı; dilim sayıları zaten hemen alttaki sekmelerde. Altında liste durumu. Tadımlık kartı yerinde kalır, bir blok aşağı iner. |
| Liste çekmecesi | "5 / 24 tercih" ve çubuk var. Doluluğun anlamı yok. | Risk dağılımı metinle ("2 riskli · 2 az riskli · 1 güvenli"). 24/24'te sakin bir tebrik satırı. |
| + ile ekleme | Profilliyken hiç geri bildirim yok. Profilsizde uzun bir açıklama. | İlk eklemede tek sefer "İlk programın listende · 1/24". Sonrakiler sessiz (düğme ✓, sayaç artar). |
| Sihirbaz | "Adım 1/3" ve üç nokta. Adımların adı yok. | Adımların adı: Alan · Şehir · Öncelik. Adım değişince ekran okuyucuya duyurulur. |
| Sıralama yok sayfası | "Geçerli bir sıralama gerekli" (robotik). İkon düğmenin başında. | "Önce sıralamanı gir" ve bunun ne işe yaradığını söyleyen tek cümle. CTA kurala uygun (ok sonda). |
| Liste hazırlanırken | Aşama mesajları var (iyi). "Sayfayı kapatma" uyarısı 12 px gri. | Önemli uyarı okunur boyda. Başlık iri. |
| Rapor satırı aç/kapa | `aria-expanded` var, `aria-controls` yok. | Açılan bölgeye kimlik ver ve bağla. |
## Skill'lerle değerlendirme (25 Eyl 2026)
- **Emil Kowalski, `emil-design-eng`:** Nadir ve hak edilmiş an küçük bir girişle kutlanır
(scale 0.95 + opaklık, güçlü ease-out, 300 ms altı); sık tekrarlanan etkileşim
canlandırılmaz; sekme (bounce) yok. Uygulandı: "Listen tamam" girişi, sihirbaz çubuğu
transform ile doluyor, dilim sekmesinde sekme kaldırıldı. Zaten uygundu: hover yalnız gerçek
imleçli cihazda (Tailwind v4), `transition-[…]` ile açık özellik, `active:scale`.
- **Emil Kowalski, `apple-design`:** Yön bulma (her ekran "neredeyim"i söyler), özerklik
(onay penceresi yerine geri al), "keyif konfeti değil, diğer yedi ilkenin sonucu".
- **Jakub Krehel, `better-ui` (ui-skills.com):** projeye eklendi
(`.claude/skills/better-ui`), başına DESIGN.md önceliği notu düşüldü.
- **Vercel Web Interface Guidelines** (`web-design-guidelines` skill'inin kaynağı): skill
eklenmedi, çünkü her çalışmada uzak bir adresten talimat çekip uyguluyor; kurallar bir kez
okunup uygulandı: yıkıcı işleme geri al, çekmecede `overscroll-contain`, "İçeriğe geç"
linki, başlıkta `text-balance`, bekleme metninde "…".
- `ui-ux-pro-max` AGENTS.md gereği kullanılmadı.
## Bu dalda yapılanlar (`tasarim/ogrenci-samimi`)
| Ekran | Yapılan |
|---|---|
| `/sonuc` | `h1` + tek cümlelik cevap ("85.000. sıradasın… 4.760 program sana açık"), liste durumu düğmesi |
| + ile ekleme | İlk programda ve 24/24'te tek seferlik onay; dolu listede ne yapılacağı |
| Bildirimler | Denendi, geri alındı: sitenin geri kalanıyla uyumsuz duruyordu (Bilal) |
| Liste çekmecesi | Risk dağılımı metinle, "Listen tamam", güvenli yoksa ipucu, silmede geri al |
| Sihirbaz | Adlı adımlar (Alan · Şehir · Öncelik), ekran okuyucuya duyuru |
| Sıralama yok | "Önce sıralamanı gir" + ne işe yaradığı |
| `/listem` | "Tercih listen" + sayılarla özet; **telefonda 434 px yana taşma hatası düzeldi** |
| Kaydet bandı | Telefonda kelime kelime kırılma düzeldi |
| Site geneli | "İçeriğe geç" linki |
## Bilal'e kalan kararlar
- **Kredi ve TL.** Aldatıcı desenler listesinde "para birimi karmaşası" var. Bildirimde "(3
kredi)" yazıyor; kredinin TL karşılığı pakete göre ≈4–5 TL. Yanına TL yazılsın mı?
- **`/sonuc` sırası.** Tadımlık 22 Eylül'de dönüşüm için bilerek ilk ekrana alındı. Sonuç
başlığı onu yaklaşık 170 px aşağı itiyor (375 px'te hâlâ ilk ekranda başlıyor). Ölçümde
"tadımlık görüntülenme" düşerse başlık küçültülür, kaldırılmaz.
- **Kutlama hareketi.** Şimdilik yalnız metin, hafif bir büyüme ve renk. Konfeti ya da ses
yok. Daha "Duolingo" isteniyorsa bir sonraki adım piksel imzasıyla kısa bir "ısınma"
(hero'daki pikseller gibi) olabilir.
## Kaynaklar
1. Duolingo, "Elevating craft: How we refreshed our core tabs", 4 Şub 2026. https://blog.duolingo.com/core-tabs-redesign/
2. Duolingo, "The habit-building research behind your Duolingo streak", 31 Oca 2022. https://blog.duolingo.com/how-duolingo-streak-builds-habit/
3. Duolingo, "Start earning brand-new Achievements", 11 Ara 2023. https://blog.duolingo.com/achievement-badges/
4. NN/g, Tanner Kohler, "Autonomy, Relatedness, and Competence in UX Design", 9 Oca 2022. https://www.nngroup.com/articles/autonomy-relatedness-competence/
5. NN/g, Therese Fessenden, "Three Pillars of User Delight", 27 Kas 2022. https://www.nngroup.com/articles/pillars-user-delight/
6. NN/g, Jakob Nielsen, "Progressive Disclosure", 2006. https://www.nngroup.com/articles/progressive-disclosure/
7. NN/g, Kate Kaplan, "Designing Empty States in Complex Applications", 19 Eyl 2021. https://www.nngroup.com/articles/empty-state-interface-design/
8. Apple HIG, Writing (güncelleme 16 Ara 2025). https://developer.apple.com/design/human-interface-guidelines/writing
9. Apple HIG, Onboarding (10 Haz 2024). https://developer.apple.com/design/human-interface-guidelines/onboarding
10. Apple HIG, Feedback. https://developer.apple.com/design/human-interface-guidelines/feedback
11. Apple HIG, Progress indicators. https://developer.apple.com/design/human-interface-guidelines/progress-indicators
12. Laws of UX (Jon Yablonski): Goal-Gradient Effect, Peak-End Rule, Hick's Law, Zeigarnik Effect. https://lawsofux.com
13. W3C WCAG 2.2: 2.5.8 Target Size (Minimum), 4.1.3 Status Messages; WAI-ARIA APG Disclosure Pattern. https://www.w3.org/WAI/
14. Harry Brignull, Deceptive Patterns: Types. https://www.deceptive.design/types

View File

@@ -213,7 +213,8 @@ Alt sayfada (`Drawer`) aynı sınıf seti `DrawerClose` üstünde kullanılır (
- İkon kutusu: `flex size-11 items-center justify-center rounded-xl bg-primary/10` içinde `size-5 text-primary` ikon. Kart içinde sayının yanında küçük hâli: `size-8 … sm:size-10` + `size-4 sm:size-5` ikon (§4.8).
- Gölge: `shadow-sm` varsayılan; hover'da `shadow-lg shadow-orange-500/5`. Başka gölge yok.
### 4.5 Risk çipi / etiketi
### 4.5 Risk çipi / etiketi — `RiskEtiketi`, `src/components/risk-etiketi.tsx`
Tablolar, çekmece ve rapor listesi `<RiskEtiketi risk={…} />` kullanır; elle yazılmaz.
Nokta + metin: `<span className="size-2 rounded-full bg-emerald-500" />` yanında `text-xs font-semibold text-emerald-600`; metin `RISK_ETIKET`'ten. Renk tek başına asla.
### 4.6 Form
@@ -222,7 +223,7 @@ shadcn `Input`, `Select`, `Textarea`, `Slider`. Etiket her alanda; hata mesajı
### 4.7 İkonlar
Yalnızca `lucide-react`. Boyut: `size-4` buton/satır içi (varsayılan), `size-5` ikon kutusu, `size-3.5` küçük çip, `size-6` üstü yalnızca boş durum. Dekoratif ikon `aria-hidden`; tek başına ikon buton `aria-label` ya da `sr-only` metin taşır. Emoji ikon olarak kullanılmaz.
### 4.8 Özet kartı — `OzetKart`, `universite-icerik.tsx`
### 4.8 Özet kartı — `OzetKart`, `src/components/ozet-kart.tsx`
Apple Fitness özet kartının sitenin ölçüleriyle hâli. Sayfada **en çok dört** tane (harita
yanındaki 2×2).
@@ -239,7 +240,11 @@ yanındaki 2×2).
ikon kutusu `flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10 sm:size-10`.
- `dl` anlamı için `dt` (başlık) DOM'da önce gelir, görsel sıra `order-*` ile kurulur.
- Telefonda kart iç genişliği ~131 px: en uzun sayı ("13.889", 36 px'te ~90 px) + 32 px ikon
sığar. Daha uzun sayı gelecekse önce ölç (§9.2), sonra yaz.
sığar. Daha uzun sayı gelecekse önce ölç (§9.2), sonra yaz. Ölçülen sınır: 36 px'te
"1.618.988" 123 px — ikonun altına girer. 6–7 haneli sayı birimle kısalır: 245.300 →
`245` + birim `bin`, 1.618.988 → `1,62` + birim `milyon` (bölüm sayfasındaki `kartSayisi`).
- Kullanıldığı yerler: üniversite ve bölüm sayfası (bölümde dört kart: Program, Devlet,
Vakıf, En iyi sıralama; `grid grid-cols-2 gap-4 lg:grid-cols-4`, haritası olmadığı için).
- Tıklanabilir kart (ör. Türkiye sırası → sıralama sayfası): kartın tamamı link olur
(`after:absolute after:inset-0` gerilmiş link), iç kaynak linki `relative z-10` ile üstte
kalır; hover §2.1.
@@ -271,6 +276,12 @@ açılan modal değil. Çekmece kabı ve çarpı `cta-sira-form.tsx`'teki çekme
kareler kısaltılır, not kısaltılır — içerik silinmez (§9.2 ölçüm).
- Sıra: gösterge kareleri → tek cümlelik not (`text-sm text-slate-500`) → ikincil buton (§4.1)
→ kaynaklar (logo solda, `text-xs` iki satır).
- **Liste çekmecesi** (`liste-cekmecesi.tsx`, 24 Eyl 2026): başlık aynı düzende (temizle
düğmesi çarpıyla aynı sınıf setinde solda). Satırlar çerçeveli kart değil, düz liste satırı
(`border-b border-slate-100`); ad §4.15 gibi sarar, risk + taban sıra adın altında, çıkar
düğmesi `size-11` **çerçevesiz `CircleMinus`** — çarpı yalnız çekmeceyi kapatır (Bilal, 25 Eyl
2026: "2 çarpı var"). Listeler uzun olduğu için bu çekmece kaydırılır (tek ekran kuralının
tek istisnası); alt bandı telefonda alt alta dizilir ki listeye en az ~280 px kalsın.
- **Arama motoru uyarısı:** çekmece içeriği sayfa açılırken HTML'de yoktur (kapalıyken
çizilmez). Google'ın görmesi gereken metin ve **zorunlu atıflar** (ör. URAP sırası + payda +
dönem + aktif bağlantı) çekmeceye taşınmaz, sayfada da görünür kalır.
@@ -332,7 +343,13 @@ verilir (logo listesi istemciye inmez).
Hareket: yay `bounce: 0, duration: 0.35`, giriş ve çıkış aynı yoldan; "hareketi azalt"ta yalnız
opaklık.
### 4.15 Liste satırı (program/üniversite listeleri) — `universite-program-tablosu.tsx`
### 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
tablosu bu sabitleri kullanır, sınıfı elle yazmaz.
- Risk sütunu (`/sonuc`) sm'den itibaren ayrı sütun; telefonda adın altındaki sayı satırına
iner. Etiket §4.5: `size-2` nokta + `text-xs font-semibold text-{renk}-600` + `RISK_ETIKET`.
- Veride BÜYÜK HARF gelen üniversite adı ekranda `trBaslikDuzeni` ile yazılır.
- **Ad kesilmez, sarar:** `whitespace-normal` + `block text-base font-semibold leading-snug`.
`truncate` ile "İngiliz Dili ve E…" yazılmaz.
- Ad linkinin hover'ı **mavi**: `transition-colors hover:text-primary hover:underline`
@@ -341,7 +358,22 @@ opaklık.
adın altındaki satıra iner: `mt-1.5 flex items-center gap-1.5 text-sm text-slate-500 sm:hidden`
içinde değer `font-semibold text-slate-900`.
- Tür çipi: `rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700`.
- Satır butonları varsayılan `size-11` (44 px); `size-8` gibi küçültme verilmez.
- Satır butonları varsayılan `size-11` (44 px); `size-8` gibi küçültme verilmez. Telefonda
iki buton alt alta durur (`inline-flex flex-col items-center gap-2 sm:flex-row`, sütun
`w-14 sm:w-28`): yan yana 96 px yiyip adı 157 px'e sıkıştırıyordu, alt alta 273 px kalıyor
(24 Eyl 2026 ölçümü).
- Rapor listesinde (`rapor-listesi.tsx`) telefonda logo gizlenir (`hidden sm:block`): solda
sıra numarası zaten satırı çapalıyor, logo adı üç satıra sıkıştırıyordu (satır 214 →
152 px). Açılan ayrıntı iç kutuları `rounded-xl`, etiket `text-sm font-semibold
text-slate-900`, metin `text-base`.
- **Geniş ekranda ince satır** (Bilal, 25 Eyl 2026: "geniş ekranda listeler çok kalın"):
sm'den itibaren tür/il/süre satırı adla aynı satıra çıkar (`SATIR_GRUP`); lg'de hücre
dolgusu `py-3` ve satır düğmeleri 36 px (`SATIR_DUGME_BOYU`, §8 masaüstü tablo istisnası).
Ölçüm 1280 px, bölüm listesi: 83 → 61 px. Telefonda değişmez (44 px düğme).
- Satırda logo varsa (bölüm sayfası) `UniLogo boy="sm"` adın solunda, `items-start`.
- Program varyantı ("… (İngilizce) (Burslu)") adın altında ayrı satır
`mt-0.5 block text-sm leading-snug text-slate-600`; tür çipine yapıştırılıp kesilmez.
- Tür çipi devlet/vakıf için de aynı slate çip; vakıf violet değildir (§2.2).
- Tablo başlığı `h-12 text-sm font-semibold text-slate-500`; hücre dolgusu `py-4 pl-4 sm:pl-5`.
## 5. Yerleşim
@@ -378,6 +410,22 @@ kartlarla aynı hizada:
yorar), form alanları, uzun rehber metni.
- Alt sayfa (§4.10) içeriği her ekranda ortalıdır.
### 5.2.1 Sayfalama — `Sayfalama`, `sayfalama.tsx`
Haplar `h-11` (44 px), hover §2.1. Telefonda (sm altı) tek satır kalsın diye yalnız Önceki ·
aktif sayfa · Sonraki görünür; diğer numaralar `hidden sm:inline-flex` ile HTML'de durur
(tarayıcı botları tüm sayfaları görür). 375 px'te 7 sayfalık şerit iki satıra kırılıyordu.
### 5.2.2 Dilim sekmeleri (Hayal / Dengeli / Güvenli) — `program-tablosu.tsx`
Tek satırda üç hap 375 px'e sığmıyordu (418 px, sayfa yana kayıyordu). Telefonda
`grid w-full grid-cols-3`, hapın içinde ikon+ad üstte, sayı altta; sm'den tek satır. Hap en az
44 px (`min-h-11`), hover §2.1.
### 5.2.3 Karşılaştırma tablosu (ana sayfa) — yatay kaydırma yok
Dört sütunlu tablo yalnız `lg`'de. Altında her özellik kendi satırında (başlık ortalı
`text-base font-semibold`), üç cevap altında üç sütun; KolayTercih sütunu `bg-primary/5`
kutuda. Sütun adları üstte yapışık: `sticky top-24 sm:top-32` (site başlığı telefonda 96 px,
sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerekiyor, olmamalı".
### 5.3 Verimli ve havadar
- İlişkili öğeler yan yana dizilir (iki sütun) — tek sütunda alt alta uzayan panel yapılmaz.
- Yan yana dizmek "hepsini tek şeride sıkıştırmak" değildir: 23 Eyl'de denenen yatay "bilgi
@@ -470,9 +518,94 @@ Bu dilde sorunların çoğu koddan görünmez (taşma, sığma, hizalanma). Tasa
### 9.4 Site taraması için denetim uyarıları
`pnpm tasarim:denetim` bu dilin eski izlerini **uyarı** olarak listeler (lint'i kırmaz, dönüşüm
listesidir): `minik-yazi` (10–11 px), `buyuk-harf-etiket` (uppercase + tracking-wide),
`mavi-hover` (§2.1; liste satırı adı istisna), `iri-sayi-tabular` (Cal Sans + tabular-nums).
`mavi-hover` (§2.1), `iri-sayi-tabular` (Cal Sans + tabular-nums).
`mavi-hover` §4.15 kanonik liste satırı adını (`text-base font-semibold leading-snug` +
`hover:text-primary hover:underline` aynı satırda) kendiliğinden atlar — istisna uyarı
vermez (Bilal'in kararı, 24 Eyl 2026). Eski `truncate text-sm` adlar yine uyarır; önce §4.15'e
çevrilir.
Bir ekranı dönüştüren ajan o ekrandaki uyarıları sıfırlar ya da neden kaldığını yazar.
**Bilerek bırakılan uyarılar (24 Eyl 2026 taraması sonunda 12):**
- `meraklisina-demo.tsx` (8 × minik-yazi): SVG `viewBox` 640 birim; 375 px'te 0,54 ölçekle
çiziliyor, 11 birimlik yazı ekranda 5,9 px (ölçüldü). 12'ye çıkarmak yalnız 6,5 px eder ve
etiketler çakışır — çözüm telefona ayrı (dikey) çizim; ayrı iş.
- `secimlerim-paneli.tsx` (4): hiçbir yerde kullanılmıyor (ölü kod); dönüştürülmedi,
silinmesi ayrı iş.
### 9.5 Bitirmeden
`pnpm tasarim:denetim` sıfır hata (uyarılar raporda listelenir) + 375 px'te tek bakış (taşma,
üst üste binme, dokunma hedefi, çekmece tek ekran) + §9.2 ölçümleri.
## 10. Samimi ton, ilerleme ve kutlama (öğrenci ekranları)
Bilal'in isteği (25 Eyl 2026): "Duolingo gibi basit ve oyunsu, Apple gibi irdeleyici, samimi."
Araştırma ve kaynaklar: `docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md`. Özü: bizim
öğrencimiz için samimi = **güven**; neşe dayatılmaz.
### 10.1 Her ekranın tek cümlelik cevabı
- Öğrenci ekranı `h1` ile açılır ve ilk paragraf öğrencinin kendi durumunu sayıyla söyler.
Örnek (`/sonuc`, `SonucBasligi`): "**85.000.** sıradasın (Sayısal). **4.760 program** sana
açık; aşağıda riskine göre ayrıldı."
- Altında liste durumu (`ListeDurumuSatiri`): boşsa ne yapılacağı, doluysa "Listende 5/24
program · 19 yer kaldı" düğmesi (çekmeceyi açar).
### 10.2 İlerleme
- Sayıyla ve dürüst: "7/24", "Adım 2/3: Şehir". Yüzde, sahte hız, yapay ilerleme yok.
- Sihirbaz adım göstergesi adlı (Alan · Şehir · Öncelik); dolum `scale-x` ile soldan,
`duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]`, hareketi azalt'ta geçişsiz.
- Liste çekmecesinde risk dağılımı metinle (`RiskEtiketi` + sayı). Riskleri bilinen 6+
tercihli listede güvenli yoksa tek cümle ipucu — vaat değil, gerekçe.
### 10.3 Kutlama
- Yalnız öğrencinin kendi yaptığı küçük adımda: ilk program (1/24) ve dolu liste (24/24).
Bir kez, sakin bir bildirimle; aradaki eklemeler sessiz (düğme ✓, sayaç artar).
- Yerleşme gibi **sonuçlarda kutlama yok**; açıkta kalan öğrenciye neşeli dil yok.
- Hareket: `starting:scale-95 starting:opacity-0` + güçlü ease-out, 300 ms altı; konfeti,
ses, sekme (bounce) yok. Hareketi azalt'ta yalnız opaklık.
### 10.4 Metin
- Sen dili, etken çatı, fiil düğme ("Listemi aç", "Sıralamamı gir"). Şirin/zekice etiket yok.
- Hata: ne olduğu + ne yapılacağı; suçlama ve ünlem yok. "Hay aksi", "Eyvah", "Oops" yok
(denetim: `samimiyetsiz-unlem`). "Buraya tıkla" yok; link ne açtığını söyler.
- Bekleme metni "…" ile biter ("Kontrol ediliyor…").
- "Kredi" dili: gerçek parayı sanal birime çevirmek aldatıcı desenler listesinde ("para
birimi karmaşası"). Kredinin TL karşılığı pakete göre değiştiği için (≈4–5 TL) ekranda TL
gösterip göstermemek Bilal'in kararına bırakıldı (25 Eyl 2026, açık).
### 10.5 Geri alınabilir işlem
- Listeden çıkarma ve listeyi temizleme anında olur ama 8 sn "Geri al" bildirimi verir;
çıkanlar eski sıralarına döner (`geriAlinabilirCikar`, `liste-cekmecesi.tsx`). Beklenen
işlemde "Emin misin?" penceresi açılmaz (Apple: beklenen kayıpta uyarı yok).
### 10.6 Anlamsal yapı
- Sayfa başına tek `h1`; bölümler `h2`. Başlıklarda `text-balance`.
- Odak almayan durum değişikliği ekran okuyucuya duyurulur: `role="status"` (liste sayacı,
sihirbaz adımı, bekleme). Bildirimler (sonner) bunu kendisi yapar.
- Açılır satır: `aria-expanded` + `aria-controls` (APG disclosure).
- Site geneli "İçeriğe geç" linki `layout.tsx`'te; içerik kabı `#icerik`.
- Çekmece kaydırma alanı `overscroll-contain`.
### 10.7 Almadıklarımız (Duolingo'dan)
Seri (streak), kayıp korkusu, puan/XP, rozet koleksiyonu, liderlik tablosu, maskot, sahte
aciliyet/kıtlık, utandırarak onay. Tercih günlük alışkanlık değil, tek seferlik kaygılı bir
karar.
### 10.8 Funnel CTA yüzeyi ve 24'lük piksel liste
Bilal'in isteği (25 Eyl 2026): "CTA alanlarını arama içindeki alandan ilham, piksel detaylı yap;
pikseller bir kümede kümeleşmesin, interaktif kreatif olsun."
- **Yüzey — `PikselCtaYuzeyi`, `src/components/piksel-cta-yuzeyi.tsx`:** arama penceresindeki
kartın dili: `rounded-3xl border border-blue-400/20 bg-slate-950 text-white`, altta yazılıp
silinen satırlar (`FUNNEL_METINLERI`, `src/lib/funnel-metinleri.ts`), tek turuncu CTA.
Kullanıldığı yerler: ana sayfa kapanış CTA'sı, `/sonuc`'taki iki sihirbaz paneli, katalog
sayfalarının altındaki `CtaSiraForm` kart varyantı (üstteki kompakt satır form olduğu için
açık zeminde kalır). Eski
`bg-primary` + köşeye kümelenmiş `PixelField` bu yüzeylerde kullanılmaz.
- **Kreatif — `PikselListe`, `src/components/piksel-liste.tsx`:** 24 kare = 24 tercih, hero'nun
nokta ölçüsüyle (hücre 10, nokta 7.4, rx 2). Örnek modda liste kendini kurar (5 hayal · 13
dengeli · 6 güvenli, 110 ms arayla), yalnız ekrandayken. İmleç yakın kareleri büyütür; kart
içindeki düğmeye imleç/odak gelince 24 kare birden dolar; dokunmada basılan kare ve komşuları
nabız atar (`kt-piksel-nabiz`). Hareketi azalt: dolu, durağan. Renk her zaman
`PikselListeAciklama` metniyle.
- **Gerçek liste:** çekmecede ilerleme çubuğunun yerinde öğrencinin kendi listesi (`gercek`,
12×2): her kare o tercihin riskiyle, riski bilinmeyen beyaz; metin karşılığı yanındaki risk
dağılımında.

View File

@@ -191,7 +191,21 @@ const KURALLAR: Kural[] = [
ad: "mavi-hover",
seviye: "uyari",
mesaj: "Hover rengi tek turuncu (orange-500); liste satırındaki ad linkiyse istisna, mavi kalır (DESIGN.md §2.1)",
bul: regexKurali(/\b(?:group-)?hover:(?:text|border|shadow)-primary\b/),
// §2.1 istisnası: §4.15 kanonik liste satırı adı (sarar, text-base
// font-semibold leading-snug + hover:text-primary hover:underline) mavi
// kalır. Eski truncate/text-sm adlar istisnaya girmez — önce §4.15'e
// çevrilir. Satırın tamamı link olduğunda group-hover çifti de sayılır.
// (Bilal'in kararı, 24 Eyl 2026: istisna uyarı vermesin.)
bul: (icerik) =>
regexKurali(/\b(?:group-)?hover:(?:text|border|shadow)-primary\b/)(icerik).filter((e) => {
const bas = icerik.lastIndexOf("\n", e.index) + 1;
const son = icerik.indexOf("\n", e.index);
const satir = icerik.slice(bas, son === -1 ? undefined : son);
return !(
/\btext-base font-semibold leading-snug\b/.test(satir) &&
/\b(?:group-)?hover:text-primary (?:group-)?hover:underline\b/.test(satir)
);
}),
},
{
ad: "iri-sayi-tabular",
@@ -199,6 +213,12 @@ const KURALLAR: Kural[] = [
mesaj: "Cal Sans iri sayıda tabular-nums 'daktilo' gibi durur; yalnız font-sans tablo sütunlarında (DESIGN.md §3)",
bul: regexKurali(/\bfont-heading\b[^"'`\n]*\btabular-nums\b|\btabular-nums\b[^"'`\n]*\bfont-heading\b/),
},
{
ad: "samimiyetsiz-unlem",
seviye: "uyari",
mesaj: "Hata/bildirim metninde 'Hay aksi/Eyvah/Oops' ya da 'buraya tıkla' yok; ne oldu + ne yapılacak (DESIGN.md §10.4)",
bul: regexKurali(/Hay aksi|Eyvah|\bOops|\bUups|[Bb]uraya tıkla|[Tt]ıklayın(?:ız)?\b/),
},
{
ad: "emoji-ikon",
seviye: "uyari",

View File

@@ -303,3 +303,21 @@
mask-image: none;
}
}
/* 24'lük piksel listede dokunma nabzı (bkz. components/piksel-liste.tsx):
basılan kare ve komşuları kısa bir büyüyüp söner. Tek seferlik, 420 ms. */
@keyframes kt-piksel-nabiz {
0% {
transform: scale(1);
}
35% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
.kt-piksel-nabiz {
animation: kt-piksel-nabiz 0.42s cubic-bezier(0.23, 1, 0.32, 1);
}

View File

@@ -82,6 +82,14 @@ export default function RootLayout({
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
>
<body className="min-h-dvh flex flex-col bg-slate-50">
{/* Klavyeyle gelen için ilk durak: üst bandı ve menüyü atla
(WCAG 2.4.1). Odaklanınca görünür, sitenin hap ölçüsünde. */}
<a
href="#icerik"
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[60] focus:inline-flex focus:h-11 focus:items-center focus:rounded-full focus:border focus:border-slate-200 focus:bg-white focus:px-4 focus:text-sm focus:font-semibold focus:text-slate-900 focus:shadow-lg focus:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
İçeriğe geç
</a>
<HeaderGate>
{/* Site geneli progressive blur — üst ve alt. Stil globals.css'te
(.kt-sayfa-blur): iOS/iPadOS'ta fixed + backdrop-filter katmanı
@@ -92,7 +100,11 @@ export default function RootLayout({
<SiteTopBanner />
<SiteHeader />
</HeaderGate>
<div className="flex min-h-0 flex-1 flex-col pb-28 print:pb-0">
<div
id="icerik"
tabIndex={-1}
className="flex min-h-0 flex-1 flex-col pb-28 outline-none print:pb-0"
>
{children}
</div>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}

View File

@@ -25,9 +25,9 @@ import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map
import {
PixelCorner,
PixelDivider,
PixelField,
SectionEyebrow,
} from "@/components/pixel-decor";
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
import { MAX_REVIZYON, URUNLER } from "@/lib/credits";
import { VERI_YIL_SAYISI } from "@/lib/veri-kapsami";
import {
@@ -282,7 +282,7 @@ export default function Home() {
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
{problems.map((p, i) => (
<Reveal key={p.title} delay={i * 0.08}>
<Card className="relative h-full overflow-hidden border-slate-200/70 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5">
<Card className="relative h-full overflow-hidden border-slate-200/70 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<PixelCorner className="absolute right-4 top-4" />
<CardHeader>
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10">
@@ -340,7 +340,7 @@ export default function Home() {
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
{steps.map((s, i) => (
<Reveal key={s.step} delay={i * 0.08} className="h-full">
<div className="relative h-full rounded-2xl border border-slate-200/70 bg-white p-8 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5">
<div className="relative h-full rounded-2xl border border-slate-200/70 bg-white p-8 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<span className="absolute -top-4 left-8 flex size-8 items-center justify-center rounded-full bg-orange-500 font-heading text-sm font-bold text-white">
{s.step}
</span>
@@ -372,9 +372,13 @@ export default function Home() {
},
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" },
].map((stat) => (
<div key={stat.label} className="p-6 text-center">
<dt className="text-sm text-slate-500">{stat.label}</dt>
<dd className="mt-1 font-heading text-3xl font-bold text-primary">
// Sayı kahramandır (DESIGN.md §1.1): iri sayı üstte, etiket
// altında; dt anlam için DOM'da önce, görsel sıra order ile.
<div key={stat.label} className="flex flex-col p-6 text-center">
<dt className="order-2 mt-2 text-sm font-semibold text-slate-500">
{stat.label}
</dt>
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary">
{stat.value}
</dd>
</div>
@@ -386,7 +390,7 @@ export default function Home() {
<ShieldCheck className="size-5" aria-hidden />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
<p className="text-sm font-semibold text-primary">
Dürüstlük notu
</p>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl">
@@ -425,45 +429,93 @@ export default function Home() {
</p>
</Reveal>
</Parallax>
{/* Tablo yatay kaydırmaz (DESIGN.md §5.1): lg altında her özellik
kendi satırında, üç cevap altında üç sütun; sütun adları
üstte yapışık durur (site başlığının hemen altında: telefonda 96 px,
sm'den itibaren 128 px — ölçüldü).
lg'de klasik dört sütunlu tablo. */}
<Parallax strength={7} className="mt-12">
<Reveal
delay={0.08}
className="overflow-x-auto rounded-2xl border border-slate-200/70 bg-white shadow-sm"
>
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="w-[40%]">Özellik</TableHead>
<TableHead className="text-center">
Ücretsiz robotlar ve sohbet botları
</TableHead>
<TableHead className="text-center">
İnsan danışman
</TableHead>
<TableHead className="text-center font-heading font-bold text-primary">
KolayTercih
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<Reveal delay={0.08}>
<div className="rounded-2xl border border-slate-200/70 bg-white shadow-sm lg:hidden">
<div
aria-hidden
className="sticky top-24 z-10 grid grid-cols-3 sm:top-32 gap-2 rounded-t-2xl border-b border-slate-100 bg-white/95 px-3 py-3 text-center text-sm font-semibold text-slate-500 backdrop-blur"
>
<span>Robotlar ve botlar</span>
<span>İnsan danışman</span>
<span className="font-heading font-bold text-primary">
KolayTercih
</span>
</div>
<ul className="divide-y divide-slate-100">
{comparison.map((row) => (
<TableRow key={row.feature}>
<TableCell className="font-medium">
<li key={row.feature} className="px-3 py-5">
<p className="px-1 text-center text-base font-semibold leading-snug text-slate-900">
{row.feature}
</TableCell>
<TableCell className="text-center">
<ComparisonCell value={row.robot} />
</TableCell>
<TableCell className="text-center">
<ComparisonCell value={row.danisman} />
</TableCell>
<TableCell className="bg-primary/5 text-center">
<ComparisonCell value={row.tercihai} />
</TableCell>
</TableRow>
</p>
<dl className="mt-3 grid grid-cols-3 items-center gap-2 text-center">
<div>
<dt className="sr-only">Robotlar ve botlar</dt>
<dd>
<ComparisonCell value={row.robot} />
</dd>
</div>
<div>
<dt className="sr-only">İnsan danışman</dt>
<dd>
<ComparisonCell value={row.danisman} />
</dd>
</div>
<div className="rounded-xl bg-primary/5 py-2">
<dt className="sr-only">KolayTercih</dt>
<dd>
<ComparisonCell value={row.tercihai} vurgu />
</dd>
</div>
</dl>
</li>
))}
</TableBody>
</Table>
</ul>
</div>
<div className="hidden overflow-hidden rounded-2xl border border-slate-200/70 bg-white shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="h-12 w-[40%] pl-5 text-sm font-semibold text-slate-500">
Özellik
</TableHead>
<TableHead className="h-12 text-center text-sm font-semibold text-slate-500">
Robotlar ve sohbet botları
</TableHead>
<TableHead className="h-12 text-center text-sm font-semibold text-slate-500">
İnsan danışman
</TableHead>
<TableHead className="h-12 text-center font-heading text-base font-bold text-primary">
KolayTercih
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{comparison.map((row) => (
<TableRow key={row.feature} className="hover:bg-transparent">
<TableCell className="whitespace-normal py-4 pl-5 text-base font-semibold text-slate-900">
{row.feature}
</TableCell>
<TableCell className="whitespace-normal py-4 text-center">
<ComparisonCell value={row.robot} />
</TableCell>
<TableCell className="whitespace-normal py-4 text-center">
<ComparisonCell value={row.danisman} />
</TableCell>
<TableCell className="whitespace-normal bg-primary/5 py-4 text-center">
<ComparisonCell value={row.tercihai} vurgu />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</Reveal>
</Parallax>
</div>
@@ -629,28 +681,14 @@ export default function Home() {
<section className="px-4 pb-24 pt-6 sm:px-6">
<Parallax strength={12} className="mx-auto max-w-6xl">
<Reveal>
<div className="relative isolate overflow-hidden rounded-3xl bg-primary px-4 py-20 text-white shadow-xl shadow-primary/20 sm:py-24">
{/* Hero'nun piksel dokusu koyu zeminde: ortası boş, kenarlara doğru yoğun */}
{/* Piksel zemin panel içinde geride kalır; taşma payı için dikeyde büyütülür */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<Parallax strength={-22} className="h-full">
<PixelField seed={8} />
</Parallax>
</div>
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<MessageCircleQuestion
className="size-10 opacity-80"
aria-hidden
/>
<h2 className="mt-6 font-heading text-3xl font-bold sm:text-4xl">
Tercih listen, pişmanlık listesi olmasın
</h2>
<KapanisCta />
</div>
</div>
{/* Funnel CTA yüzeyi (DESIGN.md §10.8): köşede kümelenen
piksel zemini yerine 24'lük listenin interaktif kreatifi */}
<PikselCtaYuzeyi metinOfseti={7}>
<h2 className="font-heading text-3xl font-bold text-balance sm:text-4xl">
Tercih listen, pişmanlık listesi olmasın
</h2>
<KapanisCta />
</PikselCtaYuzeyi>
</Reveal>
</Parallax>
</section>

View File

@@ -29,13 +29,19 @@ export default function RehberYaziPage({
<main className="mx-auto w-full max-w-3xl px-4 py-12 sm:py-16">
<nav
aria-label="breadcrumb"
className="flex items-center gap-1 text-xs text-slate-500"
className="flex items-center gap-1.5 text-sm text-slate-500"
>
<Link href="/" className="hover:text-primary hover:underline">
<Link
href="/"
className="transition-colors hover:text-orange-500 hover:underline"
>
Ana Sayfa
</Link>
<ChevronRight className="size-3" aria-hidden />
<Link href="/rehber" className="hover:text-primary hover:underline">
<ChevronRight className="size-3.5" aria-hidden />
<Link
href="/rehber"
className="transition-colors hover:text-orange-500 hover:underline"
>
Rehber
</Link>
</nav>

View File

@@ -7,6 +7,7 @@ import {
PROFIL_CEREZ_ADI,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import {
SonucBasligi,
SonucProgramTablosu,
SonucProgramTablosuSkeleton,
} from "@/features/liste/components/sonuc-program-tablosu";
@@ -133,7 +134,16 @@ export default function SonucPage({
boyada haritanın tam ölçülü kutusunu çizer, chunk inince
kutunun yalnızca İÇİ dolar. Haritanın yerini bir daha
aşağı almadan önce bu mekanizmayı kontrol et. */}
<ManuelHarita yerAyir={haritayaYerAyir} />
{/* Sayfanın tek cümlelik cevabı ve h1'i: öğrenci önce kendi
sonucunu görür (DESIGN.md §10). Harita ve tadımlık altında. */}
<SonucBasligi
sira={sira}
tur={turKey}
iller={iller}
uniturGrubu={uniturGrubu}
/>
<ManuelHarita yerAyir={haritayaYerAyir} className="mt-10" />
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. Listesi

View File

@@ -9,6 +9,7 @@ import {
ListChecks,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { FUNNEL_METINLERI } from "@/lib/funnel-metinleri";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import {
@@ -16,39 +17,6 @@ import {
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
const FUNNEL_METINLERI = [
"Sıralamana en uygun listeyi birlikte kuralım.",
"Geleceğin 24 satırlık bir plana sığar.",
"Gerçek YÖK Atlas verisiyle güvenle seç.",
"Her tercihinin bir nedeni olsun.",
"Riskleri biz hesaplayalım, kararı sen ver.",
"Sana uygun bölümler bir adım uzağında.",
"Dengeli bir liste, rahat bir tercih dönemi.",
"Tercihlerini şansa bırakma.",
"Doğru strateji, doğru yerleşme.",
"Hayalindeki bölüm için doğru plan.",
"Verilerle konuşan bir tercih listesi.",
"Üç adımda sana özel 24 tercih.",
"Tercih dönemini stressiz geçir.",
"Geçen yılın verisi, senin avantajın.",
"Güvenli ve iddialı tercihleri dengele.",
"Sıralaman ne söylüyor? Birlikte bakalım.",
"Her satırı senin için hesaplıyoruz.",
"Akıllı tercih, içi rahat bir bekleyiş.",
"Yerleşme riskini görerek seç.",
"Sana özel liste birkaç dakikada hazır.",
"İlgi alanların ve şehrin, tek listede.",
"Boş kalan tercih, kaçan fırsattır.",
"Uzman işi bir liste, senin onayınla.",
"Kararını veriye dayandır.",
"Doğru tercih, doğru bilgiyle başlar.",
"Listeni bugün kur, için rahat olsun.",
"Yerleşme riskin renklerle önünde.",
"En önemli 24 kararını birlikte verelim.",
"Senin sıralaman, sana özel plan.",
"İyi bir liste, iyi bir başlangıçtır.",
];
/** Fisher-Yates ile kopya üzerinde karıştırır; her açılışta farklı sıra. */
function karistir<T>(dizi: readonly T[]): T[] {
const kopya = [...dizi];
@@ -102,7 +70,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
return (
<section
aria-label="Tercih listeni kur"
className="mt-5 overflow-hidden rounded-2xl border border-blue-400/20 bg-[#0b1f3a] px-4 py-5 text-white sm:px-5"
className="mt-5 overflow-hidden rounded-2xl border border-blue-400/20 bg-slate-950 px-4 py-5 text-white sm:px-5"
>
<div className="grid items-center gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-5">
<div className="min-w-0">

View File

@@ -0,0 +1,58 @@
/**
* Liste satırının (DESIGN.md §4.15) sınıf dizileri — tek kaynak. Üniversite,
* bölüm ve /sonuc program tabloları (ileride rapor listesi) aynı satırı
* çizer; sınıflar her dosyada ayrı yazılınca ölçüler kayıyordu. "use client"
* yok: sunucu bileşenleri de değer olarak okuyabilsin (§9.3).
*/
/** Satırın adı (bölüm/üniversite sayfasına link): sarar, hover mavi (§2.1 istisnası). */
export const SATIR_AD =
"block text-base font-semibold leading-snug text-slate-900 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
/** Link olmayan ad (slug yoksa). */
export const SATIR_AD_DUZ =
"block text-base font-semibold leading-snug text-slate-900";
/** Adın altındaki ikinci satır (varyant ya da üniversite adı). */
export const SATIR_ALT_AD = "mt-0.5 block text-sm leading-snug text-slate-600";
/** Satırdaki ikincil link (ör. üniversite adı) — §2.1 tek turuncu. */
export const SATIR_IKINCIL_LINK =
"transition-colors hover:text-orange-500 hover:underline";
/** Tür/il/süre satırı. Geniş ekranda (sm+) üstündeki adla aynı satıra
* çıkar (SATIR_GRUP), telefonda altına iner. */
export const SATIR_META =
"mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-slate-500 sm:mt-0";
/** Ad (ya da üniversite adı) + tür/il/süre satırını geniş ekranda yan yana
* dizen sarmal. Bilal'in isteği (25 Eyl 2026): geniş ekranda satırlar üç
* katlı ve "çok kalın" duruyordu. Telefonda alt alta kalır. */
export const SATIR_GRUP =
"sm:flex sm:flex-wrap sm:items-center sm:gap-x-3 sm:gap-y-1";
/** Tür çipi (Devlet, Vakıf, SAY…). */
export const SATIR_CIP =
"shrink-0 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700";
/** Telefonda sayısal sütunun adın altına indiği satır. */
export const SATIR_MOBIL_SAYI =
"mt-1.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm text-slate-500 sm:hidden";
/** Tablo başlık hücresi. */
export const SATIR_BASLIK = "h-12 text-sm font-semibold text-slate-500";
/** Adın bulunduğu hücre. */
export const SATIR_AD_HUCRE = "w-full whitespace-normal py-4 pl-4 sm:pl-5 lg:py-3";
/** Butonların hücresi ve kabı: telefonda alt alta, sm'den yan yana.
* Masaüstünde (lg) satır ince kalsın diye dolgu bir kademe az ve satır
* düğmeleri 36 px (DESIGN.md §8 masaüstü tablo istisnası). */
export const SATIR_BUTON_HUCRE = "py-4 pr-4 text-right sm:pr-5 lg:py-3";
export const SATIR_BUTON_KABI =
"inline-flex flex-col items-center gap-2 sm:flex-row";
export const SATIR_BUTON_BASLIK = "h-12 w-14 text-right sm:w-28";
/** Satır düğmesinin boyu: dokunmada 44 px, masaüstü tabloda 36 px (§8). */
export const SATIR_DUGME_BOYU =
"size-11 min-h-11 min-w-11 lg:size-9 lg:min-h-9 lg:min-w-9";

View File

@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
/**
* Özet kartı (DESIGN.md §4.8 — Apple Fitness özet kartlarının düzeni,
* sitenin ölçüleriyle): kartın en baskın öğesi iri sayı; ikonu sağında,
* küçük ikon kutusunda (§4.4 ikon kutusunun küçük hali). Altında başlık ve
* tek satır açıklama. Telefonda kart iç genişliği ~131 px: en uzun sayı
* ("13.889", text-4xl ≈ 90 px) + size-8 ikon kutusu sığar; sm'den itibaren
* text-5xl. Üniversite ve bölüm sayfalarının tek kaynağı. `ek` verilirse
* açıklamanın altında durur (ör. Türkiye sırasındaki piksel şeridi). dl
* anlamı için dt DOM'da önce gelir, görsel sıra `order` ile kurulur; çağıran
* kartları bir `<dl>` içine koyar.
*/
export function OzetKart({
ikon: Ikon,
etiket,
deger,
birim,
alt,
ek,
className,
}: {
ikon: LucideIcon;
etiket: string;
deger: ReactNode;
birim?: string;
alt: ReactNode;
ek?: ReactNode;
className?: string;
}) {
return (
<div
className={`flex flex-col rounded-2xl border border-slate-200 bg-white p-4 sm:p-5 ${className ?? ""}`}
>
<dt className="order-2 mt-2 text-base font-semibold text-slate-900">
{etiket}
</dt>
<dd className="order-1 flex items-start justify-between gap-1.5">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-1">
<span className="font-heading text-4xl font-bold leading-none tracking-tight text-slate-900 sm:text-5xl">
{deger}
</span>
{birim ? (
<span className="text-base font-semibold text-slate-400">
{birim}
</span>
) : null}
</span>
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10 sm:size-10">
<Ikon className="size-4 text-primary sm:size-5" aria-hidden />
</span>
</dd>
<dd className="order-3 mt-0.5 text-sm text-slate-500">{alt}</dd>
{ek ? <dd className="order-4 mt-3">{ek}</dd> : null}
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { FUNNEL_METINLERI } from "@/lib/funnel-metinleri";
import { PikselListe, PikselListeAciklama } from "@/components/piksel-liste";
/** Listeyi `ofset` kadar döndürür: aynı sayfadaki yüzeyler farklı satırla
* başlar. Rastgele değil, deterministik — sunucu ve istemci aynı çizer. */
function dondur<T>(dizi: readonly T[], ofset: number): T[] {
const n = ofset % dizi.length;
return [...dizi.slice(n), ...dizi.slice(0, n)];
}
/**
* Funnel CTA yüzeyi (DESIGN.md §10.8) — arama penceresindeki kartın dili:
* koyu slate-950 zemin, ince mavi kenar, yazılıp silinen kısa satırlar,
* turuncu CTA. Pikseller bir köşede kümelenmez; yüzeyin kreatifi 24'lük
* listenin kendisidir (PikselListe). Kartın içindeki bir düğmeye ya da
* linke imleç/odak gelince 24 kare birden dolar — "listen böyle hazır olur".
*/
export function PikselCtaYuzeyi({
children,
className,
metinOfseti = 0,
kayanMetin = true,
}: {
children: ReactNode;
className?: string;
/** Aynı sayfadaki iki yüzey aynı satırla başlamasın */
metinOfseti?: number;
kayanMetin?: boolean;
}) {
const [tamamla, setTamamla] = useState(false);
const [metinler] = useState(() => dondur(FUNNEL_METINLERI, metinOfseti));
const hedefMi = (t: EventTarget | null) =>
t instanceof Element && t.closest("a, button") != null;
return (
<section
onPointerOver={(e) => setTamamla(hedefMi(e.target))}
onPointerLeave={() => setTamamla(false)}
onFocusCapture={(e) => setTamamla(hedefMi(e.target))}
onBlurCapture={() => setTamamla(false)}
className={cn(
"relative isolate overflow-hidden rounded-3xl border border-blue-400/20 bg-slate-950 text-white shadow-xl shadow-slate-900/10",
className,
)}
>
<div className="grid items-center gap-8 px-5 py-10 sm:px-10 sm:py-12 lg:grid-cols-[minmax(0,1fr)_16rem] lg:gap-12">
<div className="flex min-w-0 flex-col items-center text-center lg:items-start lg:text-left">
{children}
{kayanMetin ? (
<TypingAnimation
as="p"
words={metinler}
loop
typeSpeed={38}
deleteSpeed={18}
pauseDelay={2600}
className="mt-5 min-h-[2.75em] font-heading text-base font-bold leading-snug tracking-[-0.02em] text-slate-300 sm:min-h-[1.4em]"
/>
) : null}
</div>
<div className="order-first flex flex-col items-center lg:order-none">
<PikselListe
tamamla={tamamla}
className="w-full max-w-[9rem] sm:max-w-[11rem] lg:max-w-[14rem]"
/>
<PikselListeAciklama className="mt-4 justify-center" />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { DILIM_ETIKET } from "@/lib/risk";
/**
* 24'lük tercih listesinin piksel hâli — CTA yüzeylerinin interaktif
* kreatifi (Bilal'in isteği, 25 Eyl 2026: "pikseller bir köşede
* kümelenmesin, interaktif kreatif olsun"). Her kare bir tercih; hero'daki
* dot-matrix ölçüsüyle (hücre 10, nokta 7.4, rx 2).
*
* Davranış (DESIGN.md §10.8):
* - Örnek modda liste kendini kurar: kareler sırayla dolar (5 hayal ·
* 13 dengeli · 6 güvenli), bir süre durur, sakin söner, yeniden başlar.
* Yalnız ekrandayken çalışır.
* - İmleç ızgaranın üstündeyken yakındaki kareler hafifçe büyür.
* - `tamamla` true ise (ör. CTA düğmesine gelince) 24 kare birden dolar.
* - Dokunmada kareye basınca o kare ve komşuları kısa bir nabız atar.
* - Hareketi azalt: dolu ve durağan ızgara.
* - `gercek` verilirse döngü yok; kareler öğrencinin kendi listesidir
* (her kare o tercihin riskiyle).
* Renk tek başına anlam taşımaz: çağıran `PikselListeAciklama` ile metni
* yanına koyar.
*/
export type PikselKare = "hayal" | "dengeli" | "guvenli" | "belirsiz" | "bos";
const SUTUN = 6;
const SATIR = 4;
const TOPLAM = SUTUN * SATIR;
const CELL = 10;
const DOT = 7.4;
const INSET = (CELL - DOT) / 2;
const ORNEK: PikselKare[] = Array.from({ length: TOPLAM }, (_, i) =>
i < 5 ? "hayal" : i < 18 ? "dengeli" : "guvenli",
);
const RENK: Record<PikselKare, string> = {
hayal: "fill-red-500/85",
dengeli: "fill-amber-400/85",
guvenli: "fill-emerald-500/85",
belirsiz: "fill-white/55",
bos: "fill-white/15",
};
const ADIM_MS = 110;
const BEKLE_MS = 2600;
export function PikselListe({
tamamla = false,
gercek,
sutun = SUTUN,
className,
}: {
tamamla?: boolean;
/** Öğrencinin gerçek listesi (sırasıyla); verilirse örnek döngü kapanır */
gercek?: PikselKare[];
sutun?: number;
className?: string;
}) {
const hareketAzalt = useReducedMotion();
const satir = Math.ceil(TOPLAM / sutun);
const svgRef = useRef<SVGSVGElement>(null);
const [dolu, setDolu] = useState(hareketAzalt || gercek ? TOPLAM : 0);
const [gorunur, setGorunur] = useState(false);
const [imlec, setImlec] = useState<{ x: number; y: number } | null>(null);
const [nabiz, setNabiz] = useState<{ i: number; t: number } | null>(null);
// Yalnız ekrandayken çalış (IntersectionObserver, DESIGN.md §6)
useEffect(() => {
const el = svgRef.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setGorunur(e.isIntersecting), {
rootMargin: "0px 0px -10% 0px",
});
io.observe(el);
return () => io.disconnect();
}, []);
// Örnek döngü: 0 → 24 sırayla, bekle, sıfırla
useEffect(() => {
if (gercek || hareketAzalt || !gorunur || tamamla) return;
const id = window.setTimeout(
() => setDolu((d) => (d >= TOPLAM ? 0 : d + 1)),
dolu >= TOPLAM ? BEKLE_MS : dolu === 0 ? 500 : ADIM_MS,
);
return () => window.clearTimeout(id);
}, [dolu, gorunur, gercek, hareketAzalt, tamamla]);
const kareler: PikselKare[] = gercek
? Array.from({ length: TOPLAM }, (_, i) => gercek[i] ?? "bos")
: ORNEK.map((k, i) => (tamamla || i < dolu ? k : "bos"));
const imlecGuncelle = useCallback((e: ReactPointerEvent<SVGSVGElement>) => {
if (e.pointerType !== "mouse") return;
const svg = svgRef.current;
if (!svg) return;
const r = svg.getBoundingClientRect();
setImlec({
x: ((e.clientX - r.left) / r.width) * sutun * CELL,
y: ((e.clientY - r.top) / r.height) * satir * CELL,
});
}, [sutun, satir]);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${sutun * CELL} ${satir * CELL}`}
aria-hidden="true"
focusable="false"
onPointerMove={imlecGuncelle}
onPointerLeave={() => setImlec(null)}
className={cn("h-auto w-full touch-manipulation", className)}
>
{kareler.map((k, i) => {
const x = (i % sutun) * CELL;
const y = Math.floor(i / sutun) * CELL;
const cx = x + CELL / 2;
const cy = y + CELL / 2;
// İmlece yakın kare büyür (en çok %18), uzaklaştıkça etkisi söner
let olcek = 1;
if (imlec && !hareketAzalt) {
const d = Math.hypot(imlec.x - cx, imlec.y - cy);
olcek = 1 + Math.max(0, 1 - d / 16) * 0.18;
}
const nabizda =
nabiz &&
Math.abs((nabiz.i % sutun) - (i % sutun)) <= 1 &&
Math.abs(Math.floor(nabiz.i / sutun) - Math.floor(i / sutun)) <= 1;
return (
<rect
key={nabizda ? `${i}-${nabiz.t}` : i}
x={x + INSET}
y={y + INSET}
width={DOT}
height={DOT}
rx={2}
onPointerDown={(e) => {
if (e.pointerType === "mouse" || hareketAzalt) return;
setNabiz({ i, t: Date.now() });
}}
className={cn(
RENK[k],
"origin-center transition-[fill,transform] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] [transform-box:fill-box] motion-reduce:transition-none",
nabizda && "kt-piksel-nabiz",
)}
style={{ transform: olcek !== 1 ? `scale(${olcek})` : undefined }}
/>
);
})}
</svg>
);
}
/** Renklerin metin karşılığı (§2.2: renk tek başına anlam taşımaz). */
export function PikselListeAciklama({
className,
sayilar = { hayal: 5, dengeli: 13, guvenli: 6 },
}: {
className?: string;
sayilar?: { hayal: number; dengeli: number; guvenli: number };
}) {
const oge = (renk: string, sayi: number, etiket: string): ReactNode => (
<span className="inline-flex items-center gap-1.5">
<span className={cn("size-2 shrink-0 rounded-[2px]", renk)} aria-hidden />
{sayi} {etiket.toLocaleLowerCase("tr-TR")}
</span>
);
return (
<p
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-medium text-slate-300",
className,
)}
>
{oge("bg-red-500", sayilar.hayal, DILIM_ETIKET.hayal)}
{oge("bg-amber-400", sayilar.dengeli, DILIM_ETIKET.dengeli)}
{oge("bg-emerald-500", sayilar.guvenli, DILIM_ETIKET.garanti)}
</p>
);
}

View File

@@ -0,0 +1,32 @@
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
/**
* Risk etiketi (DESIGN.md §4.5) — tek kaynak: renkli nokta + aynı renkte
* metin. Renk hiçbir yerde tek başına durmaz; metin RISK_ETIKET'ten gelir.
* /sonuc tablosu, liste çekmecesi ve rapor listesi buradan kullanır.
*/
export const RISK_NOKTA: Record<RiskSeviyesi, string> = {
guvenli: "bg-emerald-500",
"az-riskli": "bg-amber-500",
riskli: "bg-red-500",
};
const RISK_METIN: Record<RiskSeviyesi, string> = {
guvenli: "text-emerald-600",
"az-riskli": "text-amber-600",
riskli: "text-red-600",
};
export function RiskEtiketi({ risk }: { risk: RiskSeviyesi }) {
return (
<span
className={`inline-flex items-center gap-1.5 text-xs font-semibold ${RISK_METIN[risk]}`}
>
<span
className={`size-2 shrink-0 rounded-full ${RISK_NOKTA[risk]}`}
aria-hidden
/>
{RISK_ETIKET[risk]}
</span>
);
}

View File

@@ -18,7 +18,7 @@ export function SiteFooter() {
<div className="mx-auto max-w-6xl px-4">
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
<nav aria-label="Popüler bölümler">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
<h2 className="text-sm font-semibold text-slate-900">
Popüler Bölümler
</h2>
<ul className="mt-3 grid grid-cols-1 gap-1.5">
@@ -26,7 +26,7 @@ export function SiteFooter() {
<li key={b.slug}>
<Link
href={`/bolum/${b.slug}`}
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
{b.ad} Taban Puanları
</Link>
@@ -36,14 +36,14 @@ export function SiteFooter() {
</nav>
<nav aria-label="Keşfet">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
<h2 className="text-sm font-semibold text-slate-900">
Keşfet
</h2>
<ul className="mt-3 space-y-1.5">
<li>
<Link
href="/#hero-form"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
YKS Tercih Robotu 2026
</Link>
@@ -51,7 +51,7 @@ export function SiteFooter() {
<li>
<Link
href="/bolumler"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Bölüm Taban Puanları
</Link>
@@ -59,7 +59,7 @@ export function SiteFooter() {
<li>
<Link
href="/universiteler"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Üniversite Taban Puanları
</Link>
@@ -67,7 +67,7 @@ export function SiteFooter() {
<li>
<Link
href="/rehber"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Tercih Rehberi
</Link>
@@ -75,7 +75,7 @@ export function SiteFooter() {
<li>
<Link
href="/meraklisina"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Meraklısına: Nasıl çalışır?
</Link>
@@ -84,14 +84,14 @@ export function SiteFooter() {
</nav>
<nav aria-label="Rehber yazıları">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
<h2 className="text-sm font-semibold text-slate-900">
Rehberden
</h2>
<ul className="mt-3 space-y-1.5">
<li>
<Link
href="/rehber/tercih-listesi-nasil-yapilir"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Tercih Listesi Nasıl Yapılır?
</Link>
@@ -99,7 +99,7 @@ export function SiteFooter() {
<li>
<Link
href="/rehber/kac-siralama-ile-hangi-bolume-girebilirim"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Kaç Sıralama ile Hangi Bölüme Girebilirim?
</Link>
@@ -107,7 +107,7 @@ export function SiteFooter() {
<li>
<Link
href="/rehber/olu-tercih-nedir"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Ölü Tercih Nedir?
</Link>
@@ -115,7 +115,7 @@ export function SiteFooter() {
<li>
<Link
href="/rehber/ek-yerlestirme-2026"
className="text-sm text-slate-600 hover:text-slate-900 hover:underline"
className="text-sm text-slate-600 transition-colors hover:text-orange-500 hover:underline"
>
Ek Yerleştirme Rehberi
</Link>

View File

@@ -163,7 +163,7 @@ export function SiteTopBanner() {
) : (
"--"
)}
<span className="ml-0.5 text-[10px] font-medium opacity-70">
<span className="ml-0.5 text-xs font-medium opacity-70">
{etiket}
</span>
</dd>

View File

@@ -17,7 +17,7 @@ const BOYUT: Record<"xs" | "sm" | "md" | "lg", string> = {
};
const MONOGRAM_METIN: Record<"xs" | "sm" | "md" | "lg", string> = {
xs: "text-[10px]",
xs: "text-xs",
sm: "text-xs",
md: "text-sm",
lg: "text-lg sm:text-xl",

View File

@@ -1,7 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import {
Building2,
Calculator,
ChevronRight,
GraduationCap,
Landmark,
LayoutList,
Target,
} from "lucide-react";
import {
getAllBolumler,
getAllBolumSlugs,
@@ -23,6 +31,8 @@ import {
import { BolumProgramListesi } from "./bolum-program-listesi";
import { VeriKaynagi } from "./veri-kaynagi";
import { NavBaslik } from "./nav-baslik";
import { OzetKart } from "@/components/ozet-kart";
import { PagePixelDivider } from "@/components/pixel-decor";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { Skeleton } from "@/components/ui/skeleton";
@@ -35,6 +45,32 @@ import {
const sayi = (n: number) => n.toLocaleString("tr-TR");
/**
* Özet kartında iri sayı telefonda ~94 px'e sığmalı (DESIGN.md §4.8):
* 36 px Cal Sans'ta "1.618.988" 123 px ölçüldü, ikonun altına giriyor. 6 ve
* 7 haneli sıralar birimle kısalır: 245.300 → "245" + "bin",
* 1.618.988 → "1,62" + "milyon".
*/
function kartSayisi(n: number): { deger: string; birim?: string } {
// 999.500+ "1.000 bin" olmasın diye milyona geçer
if (n >= 999_500) {
return {
deger: (n / 1e6).toLocaleString("tr-TR", { maximumFractionDigits: 2 }),
birim: "milyon",
};
}
if (n >= 100_000) return { deger: sayi(Math.round(n / 1000)), birim: "bin" };
return { deger: sayi(n) };
}
/** Lisans puan türünün kimlik hapındaki adı (DB: SAYISAL, EŞİT AĞIRLIK…). */
const PUAN_TURU_HAP: Record<string, string> = {
SAYISAL: "Sayısal puan",
"EŞİT AĞIRLIK": "Eşit ağırlık puanı",
SÖZEL: "Sözel puan",
DİL: "Dil puanı",
};
export function bolumToplamSayfa(programSayisi: number): number {
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
}
@@ -179,76 +215,126 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
// adı geçer (ziyaretçi Google'dan doğrudan /sayfa/N'ye iniyor).
const ctaBaslik = `Sıralaman ${bolum.ad} tabanlarının neresinde?`;
// Kimlik hapları (DESIGN.md §4.12): düzey + lisans puan türleri. TYT
// puanı önlisansın kendisi olduğundan ayrı hap açmaz.
const puanTurleri = [
...new Set(bolum.lisansProgramlari.map((p) => p.tur)),
].flatMap((t) => (PUAN_TURU_HAP[t] ? [PUAN_TURU_HAP[t]] : []));
const duzeyler = [
...(bolum.lisansProgramlari.length > 0 ? ["Lisans"] : []),
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
];
// Özet kartları: devlet ve vakıf ayrı sayılır; KKTC ve yurt dışı
// programları toplamda var ama iki karta da girmez (vakıf sayılmaz).
const devletUni = new Set<string>();
const vakifUni = new Set<string>();
let vakifProgram = 0;
for (const p of tumProgramlar) {
if (p.unitur === "DEVLET") devletUni.add(p.universite);
else if (p.unitur === "VAKIF" || p.unitur === "VAKIF MYO") {
vakifProgram += 1;
vakifUni.add(p.universite);
}
}
const { stats } = bolum;
const hapSinifi =
"inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800";
return (
<>
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Bölümler", path: "/bolumler" },
{ name: bolum.ad, path: `/bolum/${slug}` },
...(ilkSayfa ? [] : [{ name: `Sayfa ${sayfa}`, path: buYol }]),
])}
/>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Bölümler", path: "/bolumler" },
{ name: bolum.ad, path: `/bolum/${slug}` },
...(ilkSayfa ? [] : [{ name: `Sayfa ${sayfa}`, path: buYol }]),
])}
/>
<nav
aria-label="İçerik yolu"
className="mb-5 flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground"
>
<Link
href="/"
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
<nav
aria-label="İçerik yolu"
className="flex flex-wrap items-center justify-center gap-1.5 text-sm text-slate-500 lg:justify-start"
>
Ana sayfa
</Link>
<ChevronRight className="size-3.5" aria-hidden />
<Link
href="/bolumler"
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Bölümler
</Link>
<ChevronRight className="size-3.5" aria-hidden />
{ilkSayfa ? (
<span className="text-foreground">{bolum.ad}</span>
) : (
<>
<Link
href={`/bolum/${slug}`}
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{bolum.ad}
</Link>
<ChevronRight className="size-3.5" aria-hidden />
<span className="text-foreground">Sayfa {sayfa}</span>
</>
)}
</nav>
<Link
href="/"
className="transition-colors hover:text-orange-500 hover:underline"
>
Ana Sayfa
</Link>
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
<Link
href="/bolumler"
className="transition-colors hover:text-orange-500 hover:underline"
>
Bölümler
</Link>
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
{ilkSayfa ? (
<span className="text-slate-700">{bolum.ad}</span>
) : (
<>
<Link
href={`/bolum/${slug}`}
className="transition-colors hover:text-orange-500 hover:underline"
>
{bolum.ad}
</Link>
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
<span className="text-slate-700">Sayfa {sayfa}</span>
</>
)}
</nav>
{/* Başlık üst çubuğun altına girince üst çubukta bölüm adı belirir */}
<NavBaslik baslikId="bolum-baslik" ad={bolum.ad} />
<header className="border-b pb-7 sm:pb-9">
{/* Başlık üst çubuğun altına girince üst çubukta bölüm adı belirir
(logosu yok — DESIGN.md §4.14). Geniş ekran (lg) altında başlık
bloğu ortalı (§5.2). */}
<NavBaslik baslikId="bolum-baslik" ad={bolum.ad} />
<h1
id="bolum-baslik"
className="max-w-3xl font-heading text-3xl font-bold tracking-tight text-foreground sm:text-5xl">
className="mt-6 text-center font-heading text-3xl font-bold leading-tight text-slate-900 sm:text-5xl lg:text-left"
>
{bolum.ad}
{ilkSayfa ? "" : ` — Sayfa ${sayfa}`}
{!ilkSayfa && aralik ? (
<>
{" "}
<span className="mt-2 block text-lg font-semibold tracking-normal text-muted-foreground sm:text-2xl">
<span className="mt-2 block text-lg font-semibold text-slate-500 sm:text-2xl">
{aralik.charAt(0).toLocaleUpperCase("tr-TR") + aralik.slice(1)}
</span>
</>
) : null}
</h1>
{duzeyler.length > 0 || puanTurleri.length > 0 ? (
<p className="mt-5 flex flex-wrap items-center justify-center gap-2 lg:justify-start">
{duzeyler.map((d) => (
<span key={d} className={hapSinifi}>
<GraduationCap className="size-4 text-primary" aria-hidden />
{d}
</span>
))}
{puanTurleri.map((t) => (
<span key={t} className={hapSinifi}>
<Calculator className="size-4 text-primary" aria-hidden />
{t}
</span>
))}
</p>
) : null}
{ilkSayfa ? (
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
{sayi(tumProgramlar.length)} üniversite programının {SON_YIL} taban
puanlarını, başarı sıralamalarını ve kontenjanlarını incele.
<p className="mx-auto mt-6 max-w-3xl text-center text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0 lg:text-left">
{bolum.ad} bölümünü açan {sayi(tumProgramlar.length)} üniversite
programının {SON_YIL} taban puanlarını, başarı sıralamalarını ve
kontenjanlarını incele.{" "}
<em>
{TERCIH_YILI} tabanları, yerleştirme sonuçları açıklandığında
netleşir.
</em>
</p>
) : (
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
<p className="mx-auto mt-6 max-w-3xl text-center text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0 lg:text-left">
{bolum.ad} taban puanları tablosunun {sayfa}. sayfası (
{sayi(tumProgramlar.length)} programın{" "}
{sayi((sayfa - 1) * SAYFA_BOYU + 1)}–
@@ -257,54 +343,99 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
{aralik ? `: ${aralik}` : ""}. Listenin başı{" "}
<Link
href={`/bolum/${slug}`}
className="font-medium text-primary hover:underline"
className="font-medium text-primary transition-colors hover:text-orange-500 hover:underline"
>
ilk sayfada
</Link>
.
</p>
)}
</header>
{/* Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra girişi
başlık bloğunun hemen altında, listenin üstünde. Alttaki kart kalır. */}
<div className="mt-6">
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
</div>
<section aria-labelledby="universite-programlari" className="mt-8">
<div className="mb-4 flex items-end justify-between gap-4">
<h2
id="universite-programlari"
className="font-heading text-xl font-bold text-foreground sm:text-2xl"
>
Üniversite programları
</h2>
<span className="shrink-0 text-sm text-muted-foreground">
{sayi(tumProgramlar.length)} program
</span>
{/* Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra
girişi özet paragrafın hemen altında, listenin üstünde. Alttaki
kart kalır. */}
<div className="mt-8">
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
</div>
<BolumProgramListesi
programlar={tumProgramlar.map((program) => ({
...program,
universiteSlug: uniSlugFromAd(program.universite),
}))}
bazAd={bolum.ad}
tabanYol={`/bolum/${slug}`}
sayfa={sayfa}
/>
<VeriKaynagi className="mt-6" />
</section>
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin
sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
{ilkSayfa ? (
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<OzetKart
ikon={LayoutList}
etiket="Program"
deger={sayi(stats.toplam)}
alt={`${sayi(stats.ilSayisi)} ilde`}
/>
<OzetKart
ikon={Landmark}
etiket="Devlet"
deger={sayi(stats.devlet)}
alt={
devletUni.size > 0
? `${sayi(devletUni.size)} üniversitede`
: "bu bölümde yok"
}
/>
<OzetKart
ikon={Building2}
etiket="Vakıf"
deger={sayi(vakifProgram)}
alt={
vakifUni.size > 0
? `${sayi(vakifUni.size)} üniversitede`
: "bu bölümde yok"
}
/>
<OzetKart
ikon={Target}
etiket="En iyi sıralama"
{...(stats.minSira != null
? kartSayisi(stats.minSira)
: { deger: "—" })}
alt={`${SON_YIL} taban sırası`}
/>
</dl>
) : null}
{/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */}
<div className="mt-10">
<CtaSiraForm
konum="alt"
baslik={ctaBaslik}
aciklama={KATALOG_CTA_ACIKLAMA}
/>
</div>
<PagePixelDivider seed={83} className="mx-auto mt-12 lg:mx-0" />
<section aria-labelledby="universite-programlari" className="mt-10">
<div className="text-center lg:text-left">
<h2
id="universite-programlari"
className="font-heading text-2xl font-bold text-slate-900 sm:text-3xl"
>
Üniversite programları
</h2>
<p className="mt-2 text-base text-slate-500">
{sayi(tumProgramlar.length)} program, taban sırasına göre
</p>
</div>
<div className="mt-6">
<BolumProgramListesi
programlar={tumProgramlar.map((program) => ({
...program,
universiteSlug: uniSlugFromAd(program.universite),
}))}
bazAd={bolum.ad}
tabanYol={`/bolum/${slug}`}
sayfa={sayfa}
/>
</div>
<VeriKaynagi className="mt-6" />
</section>
{/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */}
<div className="mt-10">
<CtaSiraForm
konum="alt"
baslik={ctaBaslik}
aciklama={KATALOG_CTA_ACIKLAMA}
/>
</div>
</main>
<SiteFooter />
</>
@@ -313,16 +444,20 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
export function BolumIcerikSkeleton() {
return (
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
<Skeleton className="h-5 w-56" />
<div className="border-b pb-7 sm:pb-9">
<Skeleton className="mt-5 h-10 w-2/3 max-w-xl" />
<Skeleton className="mt-3 h-5 w-full max-w-2xl" />
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<Skeleton className="mx-auto h-5 w-56 lg:mx-0" />
<Skeleton className="mx-auto mt-6 h-10 w-2/3 max-w-xl lg:mx-0" />
<Skeleton className="mx-auto mt-5 h-10 w-48 rounded-full lg:mx-0" />
<Skeleton className="mx-auto mt-6 h-5 w-full max-w-3xl lg:mx-0" />
<div className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-2xl" />
))}
</div>
<div className="mt-8 space-y-3">
<div className="mt-16 space-y-3">
<Skeleton className="h-7 w-64" />
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
<Skeleton key={i} className="h-20 w-full rounded-2xl" />
))}
</div>
</main>

View File

@@ -15,7 +15,7 @@ function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
<li key={b.slug}>
<Link
href={`/bolum/${b.slug}`}
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 transition-colors hover:text-orange-500"
>
<span className="group-hover:underline">{b.ad}</span>
<span className="text-xs text-slate-400">

View File

@@ -12,6 +12,20 @@ import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
} from "@/components/program-liste-verileri";
import {
SATIR_AD,
SATIR_AD_DUZ,
SATIR_AD_HUCRE,
SATIR_ALT_AD,
SATIR_BASLIK,
SATIR_BUTON_BASLIK,
SATIR_BUTON_HUCRE,
SATIR_BUTON_KABI,
SATIR_CIP,
SATIR_GRUP,
SATIR_META,
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import { UniLogo } from "@/components/uni-logo";
@@ -58,7 +72,7 @@ function SiralamaDugmesi({
aktif && yon === "artan" ? "azalan" : "artan"
} sırala`}
aria-pressed={aktif}
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-sm font-semibold text-slate-500 transition-colors hover:text-orange-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span>{etiket}</span>
<span className="flex flex-col -space-y-1" aria-hidden>
@@ -66,7 +80,7 @@ function SiralamaDugmesi({
className={`size-3 ${
aktif && yon === "artan"
? "text-primary"
: "text-muted-foreground/40"
: "text-slate-300"
}`}
strokeWidth={aktif && yon === "artan" ? 3 : 2}
/>
@@ -74,7 +88,7 @@ function SiralamaDugmesi({
className={`size-3 ${
aktif && yon === "azalan"
? "text-primary"
: "text-muted-foreground/40"
: "text-slate-300"
}`}
strokeWidth={aktif && yon === "azalan" ? 3 : 2}
/>
@@ -154,14 +168,21 @@ export function BolumProgramListesi({
ref={listeRef}
className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white"
>
{/* Şişko ve sade (DESIGN.md §4.15): üniversite adı kesilmez, sarar;
satır butonları 44 px. 375 px'te yalnız ad + butonlar sütun
olarak kalır; taban sıra adın altındaki satıra iner. Sıralama
düğmeleri sütun başlıklarıyla birlikte sm'den itibaren görünür —
telefonda liste zaten taban sırasına göre dizili. */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Üniversite</TableHead>
<TableHead className="hidden text-right md:table-cell">
<TableRow className="hover:bg-transparent">
<TableHead className={`${SATIR_BASLIK} pl-4 sm:pl-5`}>
Üniversite
</TableHead>
<TableHead className={`hidden text-right lg:table-cell ${SATIR_BASLIK}`}>
Son {VERI_YIL_SAYISI} yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
<TableHead className="hidden h-12 text-right lg:table-cell">
<SiralamaDugmesi
alan="puanSon"
aktifAlan={aktifAlan}
@@ -169,7 +190,7 @@ export function BolumProgramListesi({
onSirala={sirala}
/>
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
<TableHead className="hidden h-12 text-right md:table-cell">
<SiralamaDugmesi
alan="kontenjanSon"
aktifAlan={aktifAlan}
@@ -177,7 +198,7 @@ export function BolumProgramListesi({
onSirala={sirala}
/>
</TableHead>
<TableHead className="text-right">
<TableHead className="hidden h-12 text-right sm:table-cell">
<SiralamaDugmesi
alan="siraSon"
aktifAlan={aktifAlan}
@@ -185,7 +206,7 @@ export function BolumProgramListesi({
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-24 text-right">
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
@@ -194,84 +215,87 @@ export function BolumProgramListesi({
{gosterilenProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
const devlet = p.unitur === "DEVLET";
const tabanMetni =
p.siraSon != null ? `~${sayi(p.siraSon)}.` : "—";
return (
<TableRow key={p.id} className="kt-cv-satir">
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
<TableCell className={SATIR_AD_HUCRE}>
<span className="flex items-start gap-3 sm:items-center">
{p.universiteSlug ? (
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
) : null}
<span className="min-w-0 flex-1">
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{uniAd}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{uniAd}
</span>
)}
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
<span className={SATIR_GRUP}>
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className={SATIR_AD}
>
{UNITUR_ETIKET[p.unitur]?.cip ??
trBaslikDuzeni(p.unitur)}
{uniAd}
</Link>
) : (
<span className={SATIR_AD_DUZ}>
{uniAd}
</span>
) : null}
<span className="truncate">
{p.il ? trBaslikDuzeni(p.il) : null}
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
)}
<span className={SATIR_META}>
{p.unitur ? (
<span className={SATIR_CIP}>
{UNITUR_ETIKET[p.unitur]?.cip ??
trBaslikDuzeni(p.unitur)}
</span>
) : null}
{p.il ? (
<span className="shrink-0">
{trBaslikDuzeni(p.il)}
</span>
) : null}
{p.sure ? (
<span className="shrink-0">{p.sure} yıl</span>
) : null}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span>
{varyant ? (
<span className={SATIR_ALT_AD}>
{varyant}
</span>
) : null}
<span className={SATIR_MOBIL_SAYI}>
Taban sıra
<span className="font-semibold tabular-nums text-slate-900">
{tabanMetni}
</span>
<ProgramTabanTrendi program={p} />
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<TableCell className="hidden text-right text-sm lg:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
<TableCell className="hidden text-right text-sm tabular-nums text-slate-600 lg:table-cell">
{p.puanSon != null
? p.puanSon.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
<TableCell className="hidden text-right text-sm tabular-nums text-slate-600 md:table-cell">
{p.kontenjanSon != null ? `${sayi(p.kontenjanSon)} kişi` : "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<TableCell className="hidden text-right text-base font-semibold tabular-nums text-slate-900 sm:table-cell">
<span className="inline-flex items-center gap-1.5">
<ProgramTabanTrendi program={p} />
{p.siraSon != null ? `~${sayi(p.siraSon)}.` : "—"}
{tabanMetni}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<span className="inline-flex items-center gap-1.5">
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
programId={p.id}
isim={p.isim.trim()}
universite={uniAd}
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu program={p} kaynak="bolum" />
</span>
</TableCell>
</TableRow>

View File

@@ -355,8 +355,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</span>
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500 sm:text-xs">
<SquarePen className="size-2.5 sm:size-3" aria-hidden />
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">
<SquarePen className="size-3" aria-hidden />
Değiştir
</span>
</button>
@@ -564,7 +564,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
function BaslangicIcerigi({ onSec }: { onSec: () => void }) {
return (
<div>
<p className="px-1 pb-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
<p className="px-1 pb-2.5 text-sm font-semibold text-slate-700">
Keşfet
</p>
<div className="grid grid-cols-3 gap-2">
@@ -677,11 +677,11 @@ function KesfetLinki({
</div>
<span className="min-w-0 px-2.5 pb-3 pt-2 text-center sm:px-3.5 sm:pb-3.5 sm:pt-2.5 sm:text-left">
<span className="block text-[12px] font-semibold leading-tight tracking-tight text-slate-900 sm:text-sm">
<span className="block text-sm font-semibold leading-tight tracking-tight text-slate-900">
<span className="sm:hidden">{kisaBaslik}</span>
<span className="hidden sm:inline">{baslik}</span>
</span>
<span className="mt-0.5 block text-[10px] leading-snug text-slate-500 sm:text-xs">
<span className="mt-0.5 block text-xs leading-snug text-slate-500">
{aciklama}
</span>
</span>
@@ -700,7 +700,7 @@ function SonucGrubu({
}) {
return (
<section>
<h3 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
<h3 className="mb-2 flex items-center gap-2 px-2 text-sm font-semibold text-slate-700">
{ikon}
{baslik}
</h3>

View File

@@ -6,6 +6,7 @@
// yok: YÖK Atlas 2026 SPA'sına geçerken cinsiyet panelini yayından kaldırdı
// (bkz. scripts/detay.ts).
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { useState } from "react";
import { ListChecks } from "lucide-react";
import type { ProgramNetSatiri } from "@/types/yokatlas";
@@ -96,7 +97,7 @@ export function ProgramNetleriButonu({
type="button"
title="Son yerleşenin netleri"
onClick={(e) => e.stopPropagation()}
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
className={`inline-flex ${SATIR_DUGME_BOYU} cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 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.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
>
<ListChecks className="size-4" aria-hidden />
<span className="sr-only">{isim} — son yerleşenin netleri</span>

View File

@@ -95,7 +95,9 @@ export function Sayfalama({
</Link>
);
// 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son
// 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son. Telefonda
// (sm altı) tek satır kalsın diye yalnız Önceki · aktif · Sonraki görünür;
// diğer numaralar HTML'de durur (tarayıcı botları tüm sayfaları görür).
const numaralar: (number | "...")[] = [];
if (toplamSayfa <= 7) {
for (let n = 1; n <= toplamSayfa; n++) numaralar.push(n);
@@ -116,12 +118,12 @@ export function Sayfalama({
return (
<nav
aria-label="Sayfalama"
className="mt-6 flex flex-wrap items-center justify-center gap-1.5"
className="mt-8 flex flex-wrap items-center justify-center gap-2"
>
{sayfa > 1
? gecis(
sayfa - 1,
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
"inline-flex h-11 items-center gap-1 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 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]",
<>
<ChevronLeft className="size-4" aria-hidden />
Önceki
@@ -133,7 +135,7 @@ export function Sayfalama({
n === "..." ? (
<span
key={`bosluk-${i}`}
className="px-1.5 text-sm text-slate-400"
className="hidden px-1.5 text-sm text-slate-400 sm:inline"
aria-hidden
>
…
@@ -142,8 +144,8 @@ export function Sayfalama({
gecis(
n,
n === sayfa
? "inline-flex h-9 min-w-9 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-full border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
? "inline-flex h-11 min-w-11 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "hidden h-11 min-w-11 items-center justify-center rounded-full border border-slate-200 bg-white px-2 text-sm font-semibold text-slate-700 sm:inline-flex 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]",
n,
{ aktif: n === sayfa },
)
@@ -152,7 +154,7 @@ export function Sayfalama({
{sayfa < toplamSayfa
? gecis(
sayfa + 1,
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
"inline-flex h-11 items-center gap-1 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 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]",
<>
Sonraki
<ChevronRight className="size-4" aria-hidden />

View File

@@ -73,18 +73,18 @@ export function TurkiyeSiralamasi() {
<li key={u.slug}>
<Link
href={`/universite/${u.slug}`}
className="group flex items-center gap-3 px-3 py-3 hover:bg-slate-50 sm:gap-4 sm:px-5"
className="group flex items-center gap-3 px-3 py-4 transition-colors duration-150 hover:bg-orange-50/60 sm:gap-4 sm:px-5"
>
<span className="w-9 shrink-0 text-right font-heading text-lg font-bold tabular-nums text-slate-900 sm:w-11 sm:text-xl">
<span className="w-9 shrink-0 text-right font-heading text-lg font-bold text-slate-900 sm:w-11 sm:text-xl">
{u.sira}
</span>
<UniLogo slug={u.slug} ad={ad} boy="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-800 group-hover:text-primary group-hover:underline">
<span className="block text-base font-semibold leading-snug text-slate-900 transition-colors group-hover:text-primary group-hover:underline">
{ad}
</span>
{alt ? (
<span className="block text-xs text-slate-400">{alt}</span>
<span className="mt-0.5 block text-sm text-slate-500">{alt}</span>
) : null}
</span>
<span className="hidden w-40 shrink-0 items-center gap-3 sm:flex">

View File

@@ -12,8 +12,6 @@ import {
Target,
Users,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import {
getAllUniversiteler,
getAllUniversiteSlugs,
@@ -55,6 +53,7 @@ import { UniLogo } from "@/components/uni-logo";
import { UrapLogo } from "./urap-logo";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { NavBaslik } from "./nav-baslik";
import { OzetKart } from "@/components/ozet-kart";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
import type { Program } from "@/types/yokatlas";
@@ -313,61 +312,6 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) {
);
}
/**
* Özet kartı (Apple Fitness özet kartlarının düzeni, sitenin ölçüleriyle):
* kartın en baskın öğesi iri sayı; ikonu sağında, küçük ikon kutusunda
* (DESIGN.md §4.4 ikon kutusunun küçük hali). Altında başlık ve tek satır
* açıklama. Telefonda kart iç genişliği ~131 px: en uzun sayı ("13.889",
* text-4xl ≈ 90 px) + size-8 ikon kutusu sığar; sm'den itibaren text-5xl.
* Harita yanındaki dört hücrenin tek kaynağı. `ek` verilirse açıklamanın
* altında durur (ör. Türkiye sırasındaki piksel şeridi). dl anlamı için dt
* DOM'da önce gelir, görsel sıra `order` ile kurulur.
*/
function OzetKart({
ikon: Ikon,
etiket,
deger,
birim,
alt,
ek,
className,
}: {
ikon: LucideIcon;
etiket: string;
deger: ReactNode;
birim?: string;
alt: ReactNode;
ek?: ReactNode;
className?: string;
}) {
return (
<div
className={`flex flex-col rounded-2xl border border-slate-200 bg-white p-4 sm:p-5 ${className ?? ""}`}
>
<dt className="order-2 mt-2 text-base font-semibold text-slate-900">
{etiket}
</dt>
<dd className="order-1 flex items-start justify-between gap-1.5">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-1">
<span className="font-heading text-4xl font-bold leading-none tracking-tight text-slate-900 sm:text-5xl">
{deger}
</span>
{birim ? (
<span className="text-base font-semibold text-slate-400">
{birim}
</span>
) : null}
</span>
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10 sm:size-10">
<Ikon className="size-4 text-primary sm:size-5" aria-hidden />
</span>
</dd>
<dd className="order-3 mt-0.5 text-sm text-slate-500">{alt}</dd>
{ek ? <dd className="order-4 mt-3">{ek}</dd> : null}
</div>
);
}
/**
* Dördüncü özet kartı: URAP Türkiye sırası. Açıklamanın altında sıranın
* yerini gösteren piksel şeridi (PixelSira) durur. Kartın tamamı sıralama

View File

@@ -16,7 +16,7 @@ function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
<li key={u.slug}>
<Link
href={`/universite/${u.slug}`}
className="group flex items-center gap-3 text-sm text-slate-700 hover:text-primary"
className="group flex items-center gap-3 text-sm text-slate-700 transition-colors hover:text-orange-500"
>
<UniLogo slug={u.slug} ad={u.ad} boy="sm" />
<span className="min-w-0">

View File

@@ -8,6 +8,19 @@ import {
ProgramTabanTrendi,
programEtkinSira,
} from "@/components/program-liste-verileri";
import {
SATIR_AD,
SATIR_AD_DUZ,
SATIR_AD_HUCRE,
SATIR_BASLIK,
SATIR_BUTON_BASLIK,
SATIR_BUTON_HUCRE,
SATIR_BUTON_KABI,
SATIR_CIP,
SATIR_GRUP,
SATIR_META,
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import {
@@ -50,22 +63,22 @@ export function UniversiteProgramTablosu({
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-12 pl-4 text-sm font-semibold text-slate-500 sm:pl-5">
<TableHead className={`${SATIR_BASLIK} pl-4 sm:pl-5`}>
Program
</TableHead>
<TableHead className="hidden h-12 text-right text-sm font-semibold text-slate-500 lg:table-cell">
<TableHead className={`hidden text-right lg:table-cell ${SATIR_BASLIK}`}>
Son {VERI_YIL_SAYISI} yıl
</TableHead>
<TableHead className="hidden h-12 text-right text-sm font-semibold text-slate-500 lg:table-cell">
<TableHead className={`hidden text-right lg:table-cell ${SATIR_BASLIK}`}>
{SON_YIL} puan
</TableHead>
<TableHead className="hidden h-12 text-right text-sm font-semibold text-slate-500 md:table-cell">
<TableHead className={`hidden text-right md:table-cell ${SATIR_BASLIK}`}>
Kontenjan
</TableHead>
<TableHead className="hidden h-12 text-right text-sm font-semibold text-slate-500 sm:table-cell">
<TableHead className={`hidden text-right sm:table-cell ${SATIR_BASLIK}`}>
Taban sıra
</TableHead>
<TableHead className="h-12 w-28 text-right">
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
@@ -76,28 +89,30 @@ export function UniversiteProgramTablosu({
const tabanMetni = taban != null ? `~${sayi(taban)}.` : "—";
return (
<TableRow key={p.id} className="kt-cv-satir">
<TableCell className="w-full whitespace-normal py-4 pl-4 sm:pl-5">
{p.bolumSlug ? (
<Link
href={`/bolum/${p.bolumSlug}`}
className="block text-base font-semibold leading-snug text-slate-900 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{p.isim.trim()}
</Link>
) : (
<span className="block text-base font-semibold leading-snug text-slate-900">
{p.isim.trim()}
<TableCell className={SATIR_AD_HUCRE}>
<span className={SATIR_GRUP}>
{p.bolumSlug ? (
<Link
href={`/bolum/${p.bolumSlug}`}
className={SATIR_AD}
>
{p.isim.trim()}
</Link>
) : (
<span className={SATIR_AD_DUZ}>
{p.isim.trim()}
</span>
)}
<span className={SATIR_META}>
<span className={SATIR_CIP}>
{TUR_ETIKET[p.tur] ?? p.tur}
</span>
{p.sure ? (
<span className="shrink-0">{p.sure} yıl</span>
) : null}
</span>
)}
<span className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-slate-500">
<span className="shrink-0 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700">
{TUR_ETIKET[p.tur] ?? p.tur}
</span>
{p.sure ? (
<span className="shrink-0">{p.sure} yıl</span>
) : null}
</span>
<span className="mt-1.5 flex items-center gap-1.5 text-sm text-slate-500 sm:hidden">
<span className={SATIR_MOBIL_SAYI}>
Taban sıra
<span className="font-semibold tabular-nums text-slate-900">
{tabanMetni}
@@ -124,8 +139,8 @@ export function UniversiteProgramTablosu({
{tabanMetni}
</span>
</TableCell>
<TableCell className="py-4 pr-4 text-right sm:pr-5">
<span className="inline-flex items-center gap-2">
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
programId={p.id}
isim={p.isim.trim()}

View File

@@ -58,18 +58,20 @@ export function KaydetBanner() {
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
<div
ref={gorunumRef}
className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl"
// Telefonda metin kendi satırında, düğme altında tam genişlik:
// yan yana dizince başlık kelime kelime kırılıyordu (375 px).
className="pointer-events-auto relative mx-auto flex max-w-xl flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-white p-4 pr-14 shadow-xl sm:flex-nowrap sm:gap-4"
>
{/* Kullanıcının o anki işletim sisteminin logosu: "bu tarayıcıda"
mesajını somutlaştırır */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-700">
<IsletimSistemiLogosu className="size-5" />
</div>
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
<div className="min-w-0 flex-1">
<p className="font-heading text-base font-bold text-slate-900">
Listen yalnızca bu tarayıcıda
</p>
<p className="text-xs text-slate-500">
<p className="text-sm leading-snug text-slate-500">
Giriş yap; {liste.length} seçimin hesabında saklansın, her
cihazdan aç{" "}
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
@@ -77,8 +79,7 @@ export function KaydetBanner() {
</div>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="h-11 w-full cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97] sm:w-auto"
>
<Link
href={girisUrl}
@@ -99,7 +100,7 @@ export function KaydetBanner() {
sessionStorage.setItem(KAPATMA_KEY, "1");
} catch {}
}}
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<X className="size-4" aria-hidden />
</button>

View File

@@ -12,10 +12,12 @@
// sadeleşir; X butonu her durumda kalır.
import { useState } from "react";
import { toast } from "sonner";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { PixelField } from "@/components/pixel-decor";
import { PikselListe } from "@/components/piksel-liste";
import { UniLogo } from "@/components/uni-logo";
import { RiskEtiketi } from "@/components/risk-etiketi";
import { Button } from "@/components/ui/button";
import {
Drawer,
@@ -27,6 +29,8 @@ import {
} from "@/components/ui/drawer";
import {
ArrowRight,
Check,
CircleMinus,
GripVertical,
ListChecks,
Plus,
@@ -42,8 +46,8 @@ import {
useTransform,
type PanInfo,
} from "motion/react";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri";
import { olay } from "@/lib/analitik";
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
import {
@@ -58,6 +62,7 @@ import {
cekmeceAc,
cekmeceKapat,
cikar,
listeOku,
temizle,
useCekmeceAcik,
useManuelListe,
@@ -77,12 +82,35 @@ function uniHref(ad: string): string {
return `/universite/${turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim())}`;
}
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
guvenli: "bg-emerald-500",
"az-riskli": "bg-amber-500",
riskli: "bg-red-500",
};
/**
* Geri alınabilir silme (Vercel arayüz ilkeleri: yıkıcı işlem ya onay ya
* geri al penceresi ister; Apple: beklenen işlemde uyarı penceresi açma).
* Çıkan tercihler eski sıralarına geri konur; arada eklenenler kaybolmaz.
*/
function geriAlinabilirCikar(ids: string[], baslik: string) {
const onceki = listeOku();
const cikanlar = onceki
.map((t, sira) => ({ t, sira }))
.filter(({ t }) => ids.includes(t.id));
if (cikanlar.length === 0) return;
if (ids.length === onceki.length) temizle();
else for (const id of ids) cikar(id);
toast(baslik, {
duration: 8000,
action: {
label: "Geri al",
onClick: () => {
const simdi = listeOku().filter(
(t) => !cikanlar.some((c) => c.t.id === t.id),
);
for (const { t, sira } of cikanlar) {
simdi.splice(Math.min(sira, simdi.length), 0, t);
}
yenidenSirala(simdi.slice(0, MANUEL_LISTE_MAX));
},
},
});
}
export function ListeCekmecesi() {
const router = useRouter();
@@ -94,7 +122,13 @@ export function ListeCekmecesi() {
// drawer içinde erken ölçüm satırları kaydırıyordu
const [oturdu, setOturdu] = useState(false);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
// Risk dağılımı: riskliden güvenliye, sıfır olanlar yazılmaz
const riskSayilari = (["riskli", "az-riskli", "guvenli"] as const)
.map((r) => [r, liste.filter((t) => t.risk === r).length] as const)
.filter(([, adet]) => adet > 0);
const riskBilinen = liste.filter((t) => t.risk != null).length;
const guvenliYok =
riskBilinen >= 6 && !liste.some((t) => t.risk === "guvenli");
// Profilsiz + ile eklenenlerin riski henüz hesaplanmamıştır; alt kart
// olmayan bir analizi "hazır" diye duyurmasın. Profil varken null risk,
// programın taban sırası olmadığındandır — sıra istemek yanlış olur.
@@ -123,71 +157,99 @@ export function ListeCekmecesi() {
<DrawerContent
className="min-h-0 max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
<DrawerHeader className="border-b border-slate-100 text-left md:text-left">
<div className="flex items-center justify-between gap-3">
<DrawerTitle className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Kendi Listem
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</DrawerTitle>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
title="Listeyi temizle"
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
{/* Alt sayfa başlığı (DESIGN.md §4.10): ortalı başlık + bağlam;
çarpı §4.3 kanonik sınıf setinde, temizle düğmesi aynı ölçüde
solda. */}
<DrawerHeader className="relative border-b border-slate-100 px-4 pt-5 pb-4 text-center md:text-center">
{liste.length > 0 ? (
<button
type="button"
onClick={() =>
geriAlinabilirCikar(
liste.map((t) => t.id),
`Listen temizlendi · ${liste.length} program çıkarıldı`,
)
}
title="Listeyi temizle"
className="absolute top-3 left-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
Kendi Listem
</DrawerTitle>
{/* Durum satırı: solda listenin piksel hâli (ilerleme çubuğunun
yerine), sağda sayı ve risk dağılımı — dikine uzamasın diye
yan yana (DESIGN.md §5.3). Durum ekran okuyucuya duyurulur. */}
<div className="mt-3 flex items-center justify-center gap-3">
<div
className="w-40 shrink-0 rounded-xl bg-slate-950 px-2.5 py-2"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<PikselListe
gercek={liste.map((t) =>
t.risk === "riskli"
? "hayal"
: t.risk === "az-riskli"
? "dengeli"
: t.risk === "guvenli"
? "guvenli"
: "belirsiz",
)}
sutun={12}
/>
</div>
<div className="min-w-0 text-left">
<p role="status" className="text-sm text-slate-500">
{liste.length === MANUEL_LISTE_MAX ? (
// Nadir, hak edilmiş an: küçük giriş (Emil: scale 0.95 + opaklık,
// güçlü ease-out, <300 ms). Hareketi azalt'ta yalnız opaklık.
<span className="inline-flex items-center gap-1.5 font-semibold text-primary transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] starting:scale-95 starting:opacity-0 motion-reduce:starting:scale-100">
<Check className="size-4" aria-hidden />
Listen tamam · {liste.length} / {MANUEL_LISTE_MAX}
</span>
) : (
<>
{liste.length} / {MANUEL_LISTE_MAX} tercih
</>
)}
</p>
{riskSayilari.length > 0 ? (
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
{riskSayilari.map(([seviye, adet]) => (
<span key={seviye} className="inline-flex items-center gap-1.5">
<RiskEtiketi risk={seviye} />
<span className="text-sm font-semibold text-slate-900">
{adet}
</span>
</span>
))}
</p>
) : null}
<DrawerClose className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
</div>
</div>
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${doluluk}%` }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</div>
<ul
className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500"
aria-label="Yerleşme riski renkleri"
>
{(
[
["guvenli", "yeşil güvenli"],
["az-riskli", "turuncu az riskli"],
["riskli", "kırmızı riskli"],
] as const
).map(([seviye, aciklama]) => (
<li key={seviye} className="inline-flex items-center gap-1.5">
<span
className={`size-2 shrink-0 rounded-full ${RISK_NOKTA[seviye]}`}
aria-hidden
/>
<span>{aciklama}</span>
</li>
))}
</ul>
{/* Denge ipucu: 24'lük iskeletin mantığı (alt sıralarda güvenli
programlar). Yalnız riskleri bilinen ve en az 6 tercihli
listede; vaat değil, gerekçe. */}
{guvenliYok ? (
<p className="mx-auto mt-2 max-w-sm text-center text-sm leading-relaxed text-slate-600">
Güvenli yok; alta birkaç tane eklemek riski azaltır.
</p>
) : null}
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-4"
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-2"
>
{liste.length === 0 ? (
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
@@ -196,10 +258,10 @@ export function ListeCekmecesi() {
strokeWidth={1.25}
aria-hidden
/>
<p className="relative font-heading text-sm font-bold text-slate-700">
<p className="relative font-heading text-lg font-bold text-slate-900">
Henüz program seçmedin
</p>
<p className="relative text-xs leading-relaxed text-slate-500">
<p className="relative text-sm leading-relaxed text-slate-500">
Sonuç sayfasından program ekleyerek listeni doldurmaya başla.
</p>
</div>
@@ -228,29 +290,32 @@ export function ListeCekmecesi() {
<button
type="button"
onClick={programaEkleGit}
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-full border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
liste.length > 0 ? "mt-2" : "mt-1"
className={`flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-full border border-dashed border-slate-300 text-sm font-semibold text-slate-500 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] ${
liste.length > 0 ? "mt-3" : "mt-1"
}`}
>
<Plus className="size-5" aria-hidden />
<span className="sr-only">Sonuç sayfasından program ekle</span>
Program ekle
</button>
) : null}
</div>
<DrawerFooter className="border-t border-slate-100 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<DrawerFooter className="border-t border-slate-100 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
{girisli === false ? (
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left">
<p className="min-w-0 text-xs leading-relaxed text-slate-600">
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-3 px-1 text-left">
<p className="min-w-0 text-sm leading-relaxed text-slate-600">
{/* O anki işletim sisteminin logosu — "bu tarayıcıda"yı somutlaştırır */}
<IsletimSistemiLogosu className="mr-1.5 inline size-3.5 align-[-3px] text-slate-700" />
<IsletimSistemiLogosu className="mr-1.5 inline size-4 align-[-3px] text-slate-700" />
<span className="font-semibold text-slate-800">
Listen yalnızca bu tarayıcıda.
</span>{" "}
Giriş yap; her cihazdan eriş{" "}
<CihazCifti className="align-[-3px] text-slate-400" />
</span>
<span className="hidden sm:inline">
{" "}
Giriş yap; her cihazdan eriş{" "}
<CihazCifti className="align-[-3px] text-slate-400" />
</span>
</p>
<Link
href={girisUrl}
@@ -258,27 +323,26 @@ export function ListeCekmecesi() {
cekmeceKapat();
olay("giris_cta_tiklandi", { kaynak: "liste_cekmecesi" });
}}
className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-primary transition-colors duration-200 hover:text-primary/80"
className="inline-flex min-h-11 shrink-0 items-center gap-1 text-sm font-semibold text-primary transition-colors duration-150 hover:text-orange-500"
>
Giriş yap, kaydet
<ArrowRight className="size-3.5" aria-hidden />
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
) : null}
{/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary
zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */}
<div className="relative isolate flex items-center justify-between gap-4 overflow-hidden rounded-2xl bg-primary px-5 py-5 text-left text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-6 -z-10 text-white"
>
<PixelField seed={8} />
</div>
<div className="min-w-0">
<p className="font-heading text-2xl font-bold">
{/* Telefonda alt alta (başlık tek satır, buton tam genişlik):
yan yana dizince başlık üç satıra kırılıp listenin yerini
yiyordu (24 Eyl 2026, 375 px ölçümü). */}
{/* Funnel yüzeyi (DESIGN.md §10.8): arama penceresindeki kartın
koyu zemini; listenin piksel hâli başlıkta. */}
<div className="flex flex-col gap-3 rounded-2xl border border-blue-400/20 bg-slate-950 px-5 py-4 text-left text-white sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:py-5">
<div className="min-w-0 sm:order-1 sm:flex-1">
<p className="font-heading text-xl font-bold sm:text-2xl">
{riskEksik ? "Risk analizi sıralamanı bekliyor" : "Risk analizini biz yaptık"}
</p>
<p className="mt-1 truncate text-sm leading-relaxed text-white">
<p className="mt-1 hidden truncate text-sm leading-relaxed text-slate-300 sm:block">
{riskEksik
? "Riskleri gösterelim; istersen 24'lük listeni de kur (3 kredi)."
: "Her seçimin yerleşme riski hazır — son kararı sen ver."}
@@ -287,7 +351,7 @@ export function ListeCekmecesi() {
{riskEksik ? (
<Button
size="lg"
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 active:scale-[0.97] sm:order-3 sm:w-auto"
onClick={() => {
cekmeceKapat();
// Hedef "sihirbaz": kapı kapanınca akış /sonuc'ta devam
@@ -303,7 +367,7 @@ export function ListeCekmecesi() {
<Button
asChild
size="lg"
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 active:scale-[0.97] sm:order-3 sm:w-auto"
>
<Link href="/listem" onClick={cekmeceKapat}>
Listemde aç
@@ -341,7 +405,7 @@ function CekmeceSatiri({
info.offset.x > SIL_ESIK ||
(info.offset.x > 32 && info.velocity.x > 500)
) {
cikar(tercih.id);
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
}
};
@@ -358,12 +422,12 @@ function CekmeceSatiri({
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{ scale: 1.03, zIndex: 10 }}
className="relative mb-2 select-none last:mb-0"
className="relative select-none border-b border-slate-100 last:border-b-0"
>
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
<motion.div
style={{ opacity: silGorunur }}
className="absolute inset-0 flex items-center rounded-xl bg-red-500 pl-4"
className="absolute inset-0 flex items-center bg-red-500 pl-4"
aria-hidden
>
<Trash2 className="size-4 text-white" />
@@ -377,7 +441,7 @@ function CekmeceSatiri({
dragElastic={{ left: 0, right: 0.7 }}
dragSnapToOrigin
onDragEnd={kaydirinca}
className="relative flex items-center gap-2.5 rounded-xl border border-slate-200 bg-white px-3 py-2.5"
className="relative flex items-center gap-3 bg-white py-3"
>
<button
type="button"
@@ -385,58 +449,60 @@ function CekmeceSatiri({
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
siraKontrol.start(e);
}}
className="-m-1 shrink-0 cursor-grab touch-none rounded p-1 text-slate-300 active:cursor-grabbing"
className="-ml-1 flex h-11 w-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-slate-300 transition-colors hover:text-slate-500 active:cursor-grabbing"
>
<GripVertical className="size-4" aria-hidden />
<GripVertical className="size-5" aria-hidden />
<span className="sr-only">{tercih.isim} sırasını değiştir</span>
</button>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-[11px] font-bold tabular-nums">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold text-slate-900">
{sira}
</span>
<UniLogo ad={tercih.universite} boy="xs" />
<span className="min-w-0 flex-1">
<Link
href={bolumHref(tercih.isim)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={SATIR_AD}
>
{tercih.isim}
</Link>
<span className="block truncate text-xs text-slate-500">
<span className="mt-1 flex items-center gap-2 text-sm leading-snug text-slate-600">
<UniLogo ad={tercih.universite} boy="xs" />
<Link
href={uniHref(tercih.universite)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
title={`${tercih.universite} taban puanları sayfası`}
className="hover:text-primary hover:underline"
className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
>
{tercih.universite}
{trBaslikDuzeni(tercih.universite)}
</Link>
{tercih.il ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
{tercih.risk || tercih.efektifSira != null ? (
<span className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-500">
{tercih.risk ? <RiskEtiketi risk={tercih.risk} /> : null}
{tercih.efektifSira != null ? (
<span className="inline-flex items-center gap-1.5">
Taban sıra
<span className="font-semibold tabular-nums text-slate-900">
~{tercih.efektifSira.toLocaleString("tr-TR")}.
</span>
</span>
) : null}
</span>
) : null}
</span>
{tercih.efektifSira != null ? (
<span className="shrink-0 text-xs tabular-nums text-slate-500">
~{tercih.efektifSira.toLocaleString("tr-TR")}.
</span>
) : null}
{tercih.risk ? (
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium">
<span
className={`size-2.5 shrink-0 rounded-full ${RISK_NOKTA[tercih.risk]}`}
aria-hidden
/>
{RISK_ETIKET[tercih.risk]}
</span>
) : null}
<button
type="button"
onClick={() => cikar(tercih.id)}
onClick={() =>
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className="shrink-0 cursor-pointer rounded-full p-1.5 text-slate-300 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
// Satırdan çıkarma "−" ile, çerçevesiz: kapatma çarpısıyla (§4.3)
// karışmasın diye çarpı yalnız çekmecenin kendisini kapatır.
className="inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-[background-color,color,transform] duration-150 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92]"
>
<X className="size-3.5" aria-hidden />
<CircleMinus className="size-5" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span>
</button>
</motion.div>

View File

@@ -0,0 +1,55 @@
"use client";
import { ArrowRight, Plus } from "lucide-react";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
useListeHidreMi,
useManuelListe,
} from "../liste-store";
/**
* /sonuc başlığının altındaki "listen nerede?" satırı (DESIGN.md §10):
* öğrenci kendi kurduğu listenin durumunu sayıyla görür (hedef eğimi:
* "5/24"), tek dokunuşla çekmeceyi açar. Liste boşsa ne yapılacağını söyler.
* Liste localStorage'da yaşadığı için hidrasyondan önce yer tutucu çizilir,
* satır zıplamasın.
*/
export function ListeDurumuSatiri() {
const hidre = useListeHidreMi();
const liste = useManuelListe();
if (!hidre) return <div className="mt-5 h-11" aria-hidden />;
if (liste.length === 0) {
return (
<p className="mx-auto mt-5 max-w-sm text-sm leading-relaxed text-slate-500 lg:mx-0 lg:max-w-none">
<span className="mr-1.5 inline-flex size-6 items-center justify-center rounded-full border border-slate-200 bg-white align-middle text-slate-500">
<Plus className="size-3.5" aria-hidden />
<span className="sr-only">Artı düğmesi</span>
</span>
ile beğendiğin programı listene ekle; sırasını sonra sen belirlersin.
</p>
);
}
const kalan = MANUEL_LISTE_MAX - liste.length;
return (
<div className="mt-5 flex justify-center lg:justify-start">
<button
type="button"
onClick={cekmeceAc}
className="group inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white 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 className="font-medium text-slate-500 group-hover:text-orange-500">
{kalan > 0 ? `· ${kalan} yer kaldı` : "· liste tamam"}
</span>
<ArrowRight
className="size-4 transition-transform duration-150 group-hover:translate-x-0.5"
aria-hidden
/>
</button>
</div>
);
}

View File

@@ -46,7 +46,7 @@ export function ListemButonu() {
initial={azMotion ? false : { scale: 0.6 }}
animate={{ scale: 1 }}
transition={{ type: "spring", duration: 0.4, bounce: 0.45 }}
className="flex size-5 items-center justify-center rounded-full bg-orange-500 font-heading text-[11px] font-bold tabular-nums text-white"
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>

View File

@@ -20,7 +20,13 @@ const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
riskli: 2,
};
export function ManuelHarita({ yerAyir = false }: { yerAyir?: boolean }) {
export function ManuelHarita({
yerAyir = false,
className,
}: {
yerAyir?: boolean;
className?: string;
}) {
const liste = useManuelListe();
const hidre = useListeHidreMi();
const [seciliIl, setSeciliIl] = useState<string | null>(null);
@@ -85,10 +91,10 @@ export function ManuelHarita({ yerAyir = false }: { yerAyir?: boolean }) {
// durumunda boş harita ekranda kalmaz.
if (liste.length === 0 && (hidre || !yerAyir)) return null;
// Margin yok: harita /sonuc'ta main'in İLK çocuğudur, üst boşluğunu
// main'in py-12'sinden alır (bkz. sonuc/page.tsx).
// Üst boşluk çağırandan gelir: /sonuc'ta harita sonuç başlığının altında
// durur (bkz. sonuc/page.tsx). Harita çizilmezse boşluk da oluşmaz.
return (
<div>
<div className={className}>
<TercihHaritasiLazy
pinler={pinler}
seciliIl={seciliIl}

View File

@@ -1,11 +1,14 @@
"use client";
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { ArrowRight, Check, Plus } from "lucide-react";
import { toast } from "sonner";
import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
listeOku,
useListeDoluMu,
useListedeMi,
type ManuelKaynak,
@@ -53,7 +56,11 @@ export function ProgramEkleButonu({
e.stopPropagation();
const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur });
if (sonuc.doldu) {
toast.info(`Listen ${MANUEL_LISTE_MAX} tercihe ulaştı.`);
// Ne olduğunu ve ne yapılacağını söyle (DESIGN.md §10)
toast.info(`Listen dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`, {
description: "Yeni program eklemek için listeden birini çıkar.",
action: { label: "Listeyi aç", onClick: cekmeceAc },
});
} else if (sonuc.zatenVar) {
toast.info("Bu program zaten seçtiklerinde.");
} else if (sonuc.eklendi) {
@@ -62,11 +69,27 @@ export function ProgramEkleButonu({
yol: "dogrudan",
profilli: sonuc.profilsiz ? "hayir" : "evet",
});
// Kilometre taşları (DESIGN.md §10): ilk program ve dolu liste bir kez,
// sakin bir onayla söylenir; aradaki eklemeler sessiz kalır (düğme ✓
// olur, navbar sayacı artar). Profilsiz eklemede aşağıdaki toast
// zaten açıklama taşıyor.
const adet = listeOku().length;
if (!sonuc.profilsiz && adet === 1) {
toast.success(`İlk programın listende · 1/${MANUEL_LISTE_MAX}`, {
description: "Beğendiklerini ekledikçe sırasını çekmeceden değiştirebilirsin.",
action: { label: "Listeyi aç", onClick: cekmeceAc },
});
} else if (!sonuc.profilsiz && adet === MANUEL_LISTE_MAX) {
toast.success(`Listen tamam · ${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX}`, {
description: "Şimdi sırasına bir göz at: en çok istediğin en üstte olsun.",
action: { label: "Listeyi aç", onClick: cekmeceAc },
});
}
if (sonuc.profilsiz) {
// Önce ver, sonra iste: program eklendi; sıralama sorusu değer
// karşılığında gelir. Sabit id → art arda +'larda toast yığılmaz,
// aynı toast tazelenir.
toast.success("Seçtiklerine eklendi", {
toast.success(`Listene eklendi · ${adet}/${MANUEL_LISTE_MAX}`, {
id: PROFILSIZ_EKLEME_TOAST_ID,
duration: 8000,
// Vaat, akışın gerçek sonucuna eşit olmalı: bu kapı artık yalnız
@@ -108,10 +131,10 @@ export function ProgramEkleButonu({
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Seçtiklerime ekle"
}
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
className={`inline-flex ${SATIR_DUGME_BOYU} cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
listede
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
: "border-slate-200 bg-white text-slate-500 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 disabled:opacity-40"
} ${className}`}
>
{listede ? (

View File

@@ -20,13 +20,8 @@ import type {
PuanTuruKey,
RankResultsListe,
} from "@/types/yokatlas";
import {
DILIM_ETIKET,
RISK_ETIKET,
riskHesapla,
type RiskSeviyesi,
} from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import { DILIM_ETIKET, riskHesapla } from "@/lib/risk";
import { bolumSayfaSlug, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug";
import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import {
ProgramSiraGecmisi,
@@ -46,6 +41,22 @@ import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-buto
import { preloadTercihHaritasi } from "@/features/liste/components/tercih-haritasi-lazy";
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
import { UniLogo } from "@/components/uni-logo";
import { RiskEtiketi } from "@/components/risk-etiketi";
import { Button } from "@/components/ui/button";
import {
SATIR_AD,
SATIR_AD_HUCRE,
SATIR_ALT_AD,
SATIR_BASLIK,
SATIR_BUTON_BASLIK,
SATIR_BUTON_HUCRE,
SATIR_BUTON_KABI,
SATIR_CIP,
SATIR_GRUP,
SATIR_IKINCIL_LINK,
SATIR_META,
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
const DILIMLER: {
key: DilimKey;
@@ -78,15 +89,6 @@ const DILIMLER: {
},
];
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli. Nokta
// hiçbir yerde tek başına durmaz; yanında RISK_ETIKET'ten gelen ad her ekran
// genişliğinde yazılı durur (renk tek başına anlam taşımaz).
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
guvenli: "bg-emerald-500",
"az-riskli": "bg-amber-500",
riskli: "bg-red-500",
};
const BOS_EKSTRA: Record<DilimKey, ProgramListeSatiri[]> = {
hayal: [],
dengeli: [],
@@ -195,14 +197,17 @@ export function ProgramTablosu({
navbar'daki sıralama rozetinden değiştirilir. Aktif hap, nav'daki
"Giriş yap" butonuyla aynı dilde (koyu, rounded-full) ve layoutId
ile sekmeler arasında kayarak taşınır. */}
<TabsList variant="pill" className="self-center">
<TabsList
variant="pill"
className="grid w-full grid-cols-3 self-center sm:inline-flex sm:w-auto"
>
{DILIMLER.map((d) => {
const aktif = aktifDilim === d.key;
return (
<TabsTrigger
key={d.key}
value={d.key}
className="relative cursor-pointer px-3 py-1.5 transition-colors duration-200 hover:bg-slate-100 sm:px-4 sm:py-2"
className="relative min-h-11 cursor-pointer px-2 py-1.5 text-sm font-semibold transition-colors duration-200 hover:text-orange-500 data-active:hover:text-white group-data-[variant=pill]/tabs-list:h-auto sm:px-4"
>
{aktif ? (
<motion.span
@@ -211,18 +216,22 @@ export function ProgramTablosu({
transition={
hareketAzalt
? { duration: 0 }
: { type: "spring", duration: 0.4, bounce: 0.15 }
: { type: "spring", duration: 0.35, bounce: 0 }
}
aria-hidden
/>
) : null}
<span className="relative z-10 inline-flex items-center gap-1.5">
<d.icon
className={`size-3.5 ${aktif ? d.renkAktif : d.renk}`}
aria-hidden
/>
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
<span className="relative z-10 inline-flex flex-col items-center gap-0.5 sm:flex-row sm:gap-1.5">
<span className="inline-flex items-center gap-1.5">
<d.icon
className={`size-4 ${aktif ? d.renkAktif : d.renk}`}
aria-hidden
/>
{d.etiket}
</span>
<span
className={`text-sm font-medium tabular-nums ${aktif ? "text-white/70" : "text-slate-400"}`}
>
{sonuclar.toplam[d.key].toLocaleString("tr-TR")}
</span>
</span>
@@ -236,7 +245,7 @@ export function ProgramTablosu({
const toplam = sonuclar.toplam[d.key];
return (
<TabsContent key={d.key} value={d.key}>
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
{satirlar.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-slate-500">
Bu dilimde filtrene uyan program bulunamadı.
@@ -244,17 +253,31 @@ export function ProgramTablosu({
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8 pl-4 pr-0">Risk</TableHead>
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
<TableRow className="hover:bg-transparent">
<TableHead
className={`hidden w-28 pl-4 pr-0 sm:table-cell sm:pl-5 ${SATIR_BASLIK}`}
>
Risk
</TableHead>
<TableHead className={`pl-4 ${SATIR_BASLIK}`}>
Program
</TableHead>
<TableHead
className={`hidden text-right lg:table-cell ${SATIR_BASLIK}`}
>
Son {VERI_YIL_SAYISI} yıl
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
<TableHead
className={`hidden text-right md:table-cell ${SATIR_BASLIK}`}
>
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-24 text-right">
<TableHead
className={`hidden text-right sm:table-cell ${SATIR_BASLIK}`}
>
Taban sıra
</TableHead>
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Netler ve listeye ekle</span>
</TableHead>
</TableRow>
@@ -273,17 +296,19 @@ export function ProgramTablosu({
)}
</div>
{satirlar.length < toplam ? (
<div className="mt-3 flex flex-col items-center gap-1">
<button
<div className="mt-4 flex flex-col items-center gap-2">
<Button
type="button"
size="lg"
variant="outline"
disabled={yuklenen === d.key}
onClick={() => dahaFazlaYukle(d.key, satirlar.length)}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors duration-150 hover:border-slate-300 hover:bg-slate-50 disabled:cursor-default disabled:opacity-60"
className="cursor-pointer px-6 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
>
<ChevronDown className="size-4" aria-hidden />
{yuklenen === d.key ? "Yükleniyor…" : "Daha fazla göster"}
</button>
<span className="text-xs tabular-nums text-slate-400">
<ChevronDown className="size-4" aria-hidden />
</Button>
<span className="text-sm tabular-nums text-slate-500">
{satirlar.length.toLocaleString("tr-TR")} /{" "}
{toplam.toLocaleString("tr-TR")} program
</span>
@@ -309,65 +334,76 @@ function ProgramSatiri({
const taban = programEtkinSira(p);
const risk = riskHesapla(taban, adaySira);
const devlet = p.unitur === "DEVLET";
const tabanMetni = taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—";
const bosKaldi =
p.kontenjanSon != null &&
p.yerlesenSon != null &&
p.yerlesenSon < p.kontenjanSon;
// Şişko ve sade (DESIGN.md §4.15): program adı kesilmez, üniversite adı
// altında ayrı satır; telefonda risk ve taban sıra adın altına iner,
// butonlar alt alta 44 px.
return (
<TableRow className="kt-cv-satir">
<TableCell className="pl-4 pr-0 text-xs font-medium">
{risk ? (
<span className="inline-flex items-center gap-1.5">
<span
className={`size-2.5 shrink-0 rounded-full ${RISK_NOKTA[risk]}`}
aria-hidden
/>
{RISK_ETIKET[risk]}
</span>
) : null}
<TableCell className="hidden pl-4 pr-0 sm:table-cell sm:pl-5">
{risk ? <RiskEtiketi risk={risk} /> : null}
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
<TableCell className={SATIR_AD_HUCRE}>
<span className="flex items-start gap-3 sm:items-center">
<UniLogo ad={p.universite} boy="sm" />
<span className="min-w-0 flex-1">
<Link
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={SATIR_AD}
title={`${p.isim} bölüm sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.isim}
</Link>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
<span className="truncate">
<span className={SATIR_GRUP}>
<span className={SATIR_ALT_AD}>
<Link
href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline"
className={SATIR_IKINCIL_LINK}
title={`${p.universite} taban puanları sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.universite}
{trBaslikDuzeni(p.universite)}
</Link>
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
) : null}
<span className={SATIR_META}>
<span className={SATIR_CIP}>{devlet ? "Devlet" : "Vakıf"}</span>
{p.il ? (
<span className="shrink-0">{trBaslikDuzeni(p.il)}</span>
) : null}
{p.sure ? (
<span className="shrink-0">{p.sure} yıl</span>
) : null}
{bosKaldi ? (
<span className="shrink-0 rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-700 sm:hidden">
Boş kaldı
</span>
) : null}
</span>
</span>
<span className={SATIR_MOBIL_SAYI}>
{risk ? <RiskEtiketi risk={risk} /> : null}
<span className="inline-flex items-center gap-1.5">
Taban sıra
<span className="font-semibold tabular-nums text-slate-900">
{tabanMetni}
</span>
<ProgramTabanTrendi program={p} />
</span>
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<TableCell className="hidden text-right text-sm lg:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell
className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"
className="hidden text-right text-sm tabular-nums text-slate-600 md:table-cell"
title={
p.kontenjanSon != null && p.yerlesenSon != null
? `${p.kontenjanSon} kontenjan, ${p.yerlesenSon} yerleşen`
@@ -375,38 +411,36 @@ function ProgramSatiri({
}
>
{p.kontenjanSon != null ? (
<span className="inline-flex items-center gap-1.5">
{p.yerlesenSon != null && p.yerlesenSon < p.kontenjanSon ? (
<span className="rounded bg-amber-50 px-1 py-px text-[10px] font-medium leading-4 text-amber-600">
boş kaldı
<span className="inline-flex flex-col items-end gap-1">
{p.kontenjanSon.toLocaleString("tr-TR")} kişi
{bosKaldi ? (
<span className="rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-700">
Boş kaldı
</span>
) : null}
{p.kontenjanSon.toLocaleString("tr-TR")} kişi
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<TableCell className="hidden text-right text-base font-semibold tabular-nums text-slate-900 sm:table-cell">
<span className="inline-flex items-center gap-1.5">
<ProgramTabanTrendi program={p} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
{tabanMetni}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<span className="inline-flex items-center gap-1.5">
<TableCell className={SATIR_BUTON_HUCRE}>
<span className={SATIR_BUTON_KABI}>
<ProgramNetleriButonu
programId={p.id}
isim={p.isim}
universite={p.universite}
className="size-8 min-h-8 min-w-8"
/>
<ProgramEkleButonu
program={p}
kaynak="sonuc"
adaySira={adaySira}
adayTur={tur}
className="size-8 min-h-8 min-w-8"
/>
</span>
</TableCell>

View File

@@ -1,8 +1,69 @@
import { cache } from "react";
import { siraylaProgramAra } from "../liste-queries";
import { ProgramTablosu } from "./program-tablosu";
import { ListeDurumuSatiri } from "./liste-durumu-satiri";
import { Skeleton } from "@/components/ui/skeleton";
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { trBaslikDuzeni } from "@/lib/slug";
import type { PuanTuruKey, UniturGrubu } from "@/types/yokatlas";
const sayi = (n: number) => n.toLocaleString("tr-TR");
// Başlık ve tablo aynı isteği okur; cache tek istek içinde bir kez sorgular.
// Anahtar ilkel olsun diye iller birleştirilmiş metin olarak geçer.
const sonuclariOku = cache(
(sira: number, tur: PuanTuruKey, illerAnahtari: string, tip?: UniturGrubu) =>
siraylaProgramAra(sira, tur, {
iller: illerAnahtari ? illerAnahtari.split("|") : [],
uniturGrubu: tip,
}),
);
/**
* /sonuc'un tek cümlelik cevabı (DESIGN.md §10): sayfanın h1'i, öğrencinin
* sırası ve kaç programın açık olduğu. Tadımlık ve tablo bunun altında.
* Geniş ekran altında ortalı (§5.2).
*/
export function SonucBasligi({
sira,
tur,
iller,
uniturGrubu,
}: {
sira: number;
tur: PuanTuruKey;
iller?: string[];
uniturGrubu?: UniturGrubu;
}) {
const { toplam } = sonuclariOku(sira, tur, (iller ?? []).join("|"), uniturGrubu);
const acik = toplam.hayal + toplam.dengeli + toplam.garanti;
const filtre = [
...(iller ?? []).map(trBaslikDuzeni),
...(uniturGrubu === "devlet"
? ["yalnız devlet"]
: uniturGrubu === "vakif"
? ["yalnız vakıf"]
: []),
];
return (
<header className="text-center lg:text-left">
<h1 className="font-heading text-3xl font-bold leading-tight text-balance text-slate-900 sm:text-5xl">
Sıralamanla açılan programlar
</h1>
<p className="mx-auto mt-4 max-w-2xl text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0">
<strong className="font-semibold text-slate-900">{sayi(sira)}.</strong>{" "}
sıradasın ({PUAN_TURU_ETIKET[tur] ?? tur}).{" "}
{filtre.length > 0 ? `Seçimlerine göre (${filtre.join(", ")}) ` : ""}
<strong className="font-semibold text-slate-900">
{sayi(acik)} program
</strong>{" "}
sana açık; aşağıda riskine göre ayrıldı.
</p>
<ListeDurumuSatiri />
</header>
);
}
// /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa
// yalnızca kompoze eder.
export function SonucProgramTablosu({
@@ -16,7 +77,7 @@ export function SonucProgramTablosu({
iller?: string[];
uniturGrubu?: UniturGrubu;
}) {
const sonuclar = siraylaProgramAra(sira, tur, { iller, uniturGrubu });
const sonuclar = sonuclariOku(sira, tur, (iller ?? []).join("|"), uniturGrubu);
return (
<ProgramTablosu
sonuclar={sonuclar}

View File

@@ -513,10 +513,10 @@ export function TercihHaritasi({
<button
type="button"
onClick={onTercihSec ? () => onTercihSec(sira) : undefined}
className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1.5 pl-1.5 pr-3 text-xs transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
className="flex min-h-11 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1.5 pl-1.5 pr-3.5 text-sm transition-[background-color,border-color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 active:scale-[0.97]"
>
<span
className="flex size-5 items-center justify-center rounded-full font-heading text-[10px] font-bold text-white"
className="flex size-7 shrink-0 items-center justify-center rounded-full font-heading text-xs font-bold text-white"
style={{ backgroundColor: RISK_RENK[pin.risk] }}
>
{sira}
@@ -543,7 +543,7 @@ export function TercihHaritasi({
{bolum || kisaUniAdi(pin.universite)}
</span>
{bolum ? (
<span className="max-w-48 truncate text-[10px] text-slate-500">
<span className="max-w-48 truncate text-xs text-slate-500">
{kisaUniAdi(pin.universite)}
</span>
) : null}

View File

@@ -7,6 +7,7 @@ import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "sonner";
import { TransitionPanel } from "@/components/core/transition-panel";
import { SectionEyebrow } from "@/components/pixel-decor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -295,11 +296,9 @@ function KapıIcerik({
<div ref={panelRef} className="flex flex-col gap-5 px-px">
<div>
{duzenle ? (
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Tercihlerini güncelle
</p>
<SectionEyebrow>Tercihlerini güncelle</SectionEyebrow>
) : null}
<h2 className="mt-2 font-heading text-2xl font-bold text-slate-900 first:mt-0">
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900 first:mt-0">
{duzenle
? "Seçimlerini değiştir"
: "Tercihine ışık tutabilmemiz için önce ne istediğini öğrenelim"}
@@ -348,11 +347,9 @@ function KapıIcerik({
>
<div>
{duzenle ? (
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Sıralama ve tercihler
</p>
<SectionEyebrow>Sıralama ve tercihler</SectionEyebrow>
) : null}
<h2 className="mt-2 font-heading text-xl font-bold text-slate-900 first:mt-0">
<h2 className="mt-3 font-heading text-xl font-bold text-slate-900 first:mt-0">
{duzenle
? "Yeni sıralaman nedir?"
: "Başarı sıralaman nedir?"}
@@ -366,7 +363,7 @@ function KapıIcerik({
<div className="flex flex-col gap-2">
<label
htmlFor="kapisi-siralama"
className="text-xs font-semibold uppercase tracking-wide text-slate-400"
className="text-sm font-semibold text-slate-700"
>
Başarı sıralaması ve puan türü
</label>

View File

@@ -2,13 +2,26 @@ import { CheckCircle2, X } from "lucide-react";
// Ana sayfadaki karşılaştırma tablosunun hücresi: metin değeri düz yazılır,
// boolean değer Var/Yok ikonuna çevrilir.
export function ComparisonCell({ value }: { value: boolean | string }) {
export function ComparisonCell({
value,
vurgu = false,
}: {
value: boolean | string;
/** KolayTercih sütunu: metin koyu ve kalın */
vurgu?: boolean;
}) {
if (typeof value === "string") {
return <span className="text-sm font-medium">{value}</span>;
return (
<span
className={`block text-sm leading-snug ${vurgu ? "font-semibold text-slate-900" : "font-medium text-slate-600"}`}
>
{value}
</span>
);
}
return value ? (
<CheckCircle2 className="mx-auto size-5 text-primary" aria-label="Var" />
<CheckCircle2 className="mx-auto size-6 text-primary" aria-label="Var" />
) : (
<X className="mx-auto size-5 text-muted-foreground/50" aria-label="Yok" />
<X className="mx-auto size-5 text-slate-300" aria-label="Yok" />
);
}

View File

@@ -127,7 +127,7 @@ export function SosyalKanitGovde({ ilk }: { ilk: SosyalKanitVerisi }) {
className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-4 py-3.5"
>
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-heading text-3xl font-bold tabular-nums leading-none text-slate-900">
<span className="font-heading text-3xl font-bold leading-none text-slate-900">
{sayi(gosterilenListe)}
</span>
<span className="text-sm leading-snug text-slate-600">

View File

@@ -304,8 +304,8 @@ export function ListeUretici({
onClick={() => profil && void uret(profil)}
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<RefreshCcw className="size-4" aria-hidden />
Tekrar dene
<RefreshCcw className="size-4" aria-hidden />
</Button>
{profil ? (
<p className="mt-4 text-xs text-slate-500">
@@ -358,7 +358,7 @@ function SunucuYokEkrani() {
disabled={deniyor}
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{deniyor ? "Kontrol ediliyor" : "Listemi aç"}
{deniyor ? "Kontrol ediliyor…" : "Listemi aç"}
<ArrowRight className="size-4" aria-hidden />
</Button>
<p aria-live="polite" className="mt-4 text-xs text-slate-500">
@@ -401,7 +401,7 @@ function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
return (
<section aria-live="polite">
<header className="flex flex-col items-center gap-1.5 text-center">
<h1 className="font-heading text-xl font-bold sm:text-2xl">
<h1 className="font-heading text-2xl font-bold text-slate-900 sm:text-3xl">
Listen hazırlanıyor
</h1>
{uzunSurdu ? (
@@ -426,15 +426,15 @@ function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
</Button>
</>
) : (
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
<p className="text-sm font-medium text-slate-600">
Yarım dakika kadar sürebilir; bu sayfayı kapatma.
</p>
)}
</header>
{/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda,
danışman sağda — üretim biter bitmez ekran aynı yerlere oturur. */}
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<div className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
{/* Gelmekte olan tercih listesinin iskeleti */}
<div className="flex min-w-0 flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-6 sm:p-8">
<div className="flex items-center justify-between pb-2">

View File

@@ -39,6 +39,11 @@ import {
} from "@/features/liste/hooks/use-tercih-profili";
import { UNIVERSITE_TIPI_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { PUAN_TURLERI } from "@/types/yokatlas";
import { DILIM_ETIKET } from "@/lib/risk";
// Başlık satırındaki ikincil düğmeler: 44 px, hover tek turuncu (§4.1, §2.1)
const IKINCIL_BUTON =
"h-11 cursor-pointer px-4 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]";
export function ListemGovde({
rapor,
@@ -94,6 +99,10 @@ export function ListemGovde({
);
const acikSatir = kilitliBaslangic ?? rapor.tercihler.length;
const dilimOzeti = useMemo(() => {
const say = (d: string) => rapor.tercihler.filter((t) => t.dilim === d).length;
return `${rapor.tercihler.length} tercih: ${say("hayal")} ${DILIM_ETIKET.hayal.toLocaleLowerCase("tr-TR")} · ${say("dengeli")} ${DILIM_ETIKET.dengeli.toLocaleLowerCase("tr-TR")} · ${say("garanti")} ${DILIM_ETIKET.garanti.toLocaleLowerCase("tr-TR")}`;
}, [rapor]);
// Profil client'ta (localStorage) çözülür: ilk render'da null döner, yani
// SSR ile aynı "Tercihlerimi düzenle" hâli basılır — hidrasyon uyuşmazlığı
@@ -148,7 +157,7 @@ export function ListemGovde({
);
return (
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<div className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<div className="flex min-w-0 flex-col gap-6">
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
<section
@@ -181,11 +190,21 @@ export function ListemGovde({
aksiyonları bu kutunun kendi başlık satırında yaşar. */}
<section className={`min-w-0${kilitAcilisi ? " kt-unblur" : ""}`}>
<div className="flex flex-wrap items-center justify-between gap-3 pb-1">
<h1 className="min-w-0 font-heading text-xl font-bold tracking-tight sm:text-2xl">
{hasPaket
? "Yapay Zeka tercih listen"
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
</h1>
{/* Sayfa nerede olduğunu söyler (DESIGN.md §10.1): başlık yer,
altındaki satır listenin sayılarla özeti. */}
<div className="min-w-0">
<h1 className="font-heading text-2xl font-bold text-balance text-slate-900 sm:text-3xl">
Tercih listen
</h1>
<p className="mt-1 text-base text-slate-600">
{dilimOzeti}
{hasPaket ? null : (
<span className="block text-sm text-slate-500">
Veriyi ücretsiz incele, kararı Yapay Zeka ile netleştir.
</span>
)}
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte
TercihProfiliKapisi) profili günceller, /listem'e döner.
@@ -200,9 +219,8 @@ export function ListemGovde({
{farkVar ? (
<Button
type="button"
size="sm"
onClick={() => setFarkModali(true)}
className="cursor-pointer transition-[background-color,transform] duration-200 active:scale-[0.97]"
className="h-11 cursor-pointer px-4 transition-[background-color,transform] duration-200 active:scale-[0.97]"
>
<RefreshCcw className="size-4" aria-hidden />
Tercihlerin değişmiş
@@ -211,9 +229,8 @@ export function ListemGovde({
<Button
type="button"
variant="outline"
size="sm"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer"
className={IKINCIL_BUTON}
>
<SlidersHorizontal className="size-4" aria-hidden />
Tercihlerimi düzenle
@@ -224,8 +241,7 @@ export function ListemGovde({
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
className={IKINCIL_BUTON}
>
<Link href="#revizyon">
<PencilLine className="size-4" aria-hidden />
@@ -235,8 +251,7 @@ export function ListemGovde({
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
className={IKINCIL_BUTON}
>
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
@@ -372,7 +387,7 @@ export function ListemGovde({
</Link>
</Button>
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<ShieldCheck className="size-3.5" aria-hidden />
iyzico güvenli ödeme

View File

@@ -189,7 +189,7 @@ export function ListemIcerikSkeleton() {
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<div className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<div className="flex flex-col gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />

View File

@@ -26,12 +26,28 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import { bolumSayfaSlug, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug";
import { UNITUR_ETIKET } from "@/lib/unitur";
import { RiskEtiketi } from "@/components/risk-etiketi";
import {
SATIR_AD,
SATIR_AD_DUZ,
SATIR_AD_HUCRE,
SATIR_ALT_AD,
SATIR_BASLIK,
SATIR_BUTON_BASLIK,
SATIR_BUTON_HUCRE,
SATIR_CIP,
SATIR_DUGME_BOYU,
SATIR_GRUP,
SATIR_IKINCIL_LINK,
SATIR_META,
SATIR_MOBIL_SAYI,
} from "@/components/liste-satiri";
import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import {
DILIM_ETIKET,
RISK_ETIKET,
dilimdenFarkliRiskEtiketi,
dilimdenRisk,
riskHesapla,
@@ -39,13 +55,11 @@ import {
} from "@/lib/risk";
import type { Dilim } from "@/lib/rapor-havuzu";
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
// Nokta hiçbir yerde tek başına durmaz; yanındaki etiket her ekran
// genişliğinde yazılı durur (renk tek başına anlam taşımaz).
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = {
riskli: { nokta: "bg-red-500", icon: Rocket },
"az-riskli": { nokta: "bg-amber-500", icon: Scale },
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
// Detay başlığındaki dilim ikonu; risk etiketi RiskEtiketi'nden (§4.5).
const RISK_STIL: Record<RiskSeviyesi, { icon: typeof Rocket }> = {
riskli: { icon: Rocket },
"az-riskli": { icon: Scale },
guvenli: { icon: ShieldCheck },
};
// Sütun sayısı: # · Program · Son N yıl · Risk · Taban sıra · detay oku
@@ -78,26 +92,28 @@ export function RaporSatirDetayi({
const farkliRisk = dilimdenFarkliRiskEtiketi(dilim, risk);
return (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-slate-500">
<stil.icon className="size-4" aria-hidden />
<span className="font-semibold text-slate-700">
{DILIM_ETIKET[dilim as Dilim] ?? dilim}
{farkliRisk ? ` · ${farkliRisk}` : null}
</span>
{unitur ? <span>· {unitur}</span> : null}
{unitur ? (
<span>· {UNITUR_ETIKET[unitur]?.cip ?? unitur}</span>
) : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
<dl className="grid gap-3 text-base leading-relaxed sm:grid-cols-2">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<dt className="text-sm font-semibold text-slate-900">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{gerekce}</dd>
<dd className="mt-1 text-slate-600">{gerekce}</dd>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<dt className="text-sm font-semibold text-slate-900">
Risk ve Yapay Zeka yorumu
</dt>
<dd className="mt-1 text-slate-700">
<dd className="mt-1 text-slate-600">
{riskNotu} {trendOzeti}
</dd>
</div>
@@ -181,11 +197,19 @@ export function RaporListesi({
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const seri = raporSiraSerisi(p?.siraGecmisi, p?.ilkYil);
const taban = p?.efektifSira ?? programEtkinSira(seri);
const acikMi = acik.has(t.sira);
const devlet = p?.unitur === "DEVLET";
const tabanMetni = taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—";
const riskHucresi = analizKilitli ? (
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
Pakette
</span>
) : (
<RiskEtiketi risk={risk} />
);
return (
<Fragment key={t.sira}>
@@ -197,92 +221,88 @@ export function RaporListesi({
onClick={() => tiklandi(t.sira)}
className="kt-cv-satir cursor-pointer"
>
<TableCell className="pl-4 pr-0">
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
<TableCell className="pl-4 pr-0 align-top sm:pl-5 sm:align-middle">
<span className="mt-4 flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold text-slate-900 sm:mt-0">
{t.sira}
</span>
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p?.universite ? <UniLogo ad={p.universite} boy="sm" /> : null}
<TableCell className={SATIR_AD_HUCRE}>
<span className="flex items-start gap-3 sm:items-center">
{/* Telefonda logo yok: sıra numarası satırı zaten çapalıyor,
logo adı üç satıra sıkıştırıyordu (375 px ölçümü). */}
{p?.universite ? (
<span className="hidden sm:block">
<UniLogo ad={p.universite} boy="sm" />
</span>
) : null}
<span className="min-w-0 flex-1">
{p ? (
<Link
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={SATIR_AD}
title={`${p.isim} bölüm sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.isim}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{t.programId}
</span>
<span className={SATIR_AD_DUZ}>{t.programId}</span>
)}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
{p?.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
) : null}
<span className="truncate">
{p?.universite ? (
<span className={SATIR_GRUP}>
{p?.universite ? (
<span className={SATIR_ALT_AD}>
<Link
href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline"
className={SATIR_IKINCIL_LINK}
title={`${p.universite} taban puanları sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.universite}
{trBaslikDuzeni(p.universite)}
</Link>
</span>
) : null}
<span className={SATIR_META}>
{p?.unitur ? (
<span className={SATIR_CIP}>{devlet ? "Devlet" : "Vakıf"}</span>
) : null}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
{p?.il ? (
<span className="shrink-0">{trBaslikDuzeni(p.il)}</span>
) : null}
</span>
</span>
<span className={SATIR_MOBIL_SAYI}>
{riskHucresi}
<span className="inline-flex items-center gap-1.5">
Taban sıra
<span className="font-semibold tabular-nums text-slate-900">
{tabanMetni}
</span>
<ProgramTabanTrendi program={seri} />
</span>
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<TableCell className="hidden text-right text-sm lg:table-cell">
<ProgramSiraGecmisi program={seri} />
</TableCell>
<TableCell className="text-xs font-medium">
{analizKilitli ? (
<span className="inline-flex items-center gap-1.5 text-slate-500">
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
<span className="sr-only sm:not-sr-only">Pakette</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<span
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
aria-hidden
/>
<span>{RISK_ETIKET[risk]}</span>
</span>
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<TableCell className="hidden sm:table-cell">{riskHucresi}</TableCell>
<TableCell className="hidden text-right text-base font-semibold tabular-nums text-slate-900 sm:table-cell">
<span className="inline-flex items-center gap-1.5">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
{tabanMetni}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<TableCell className={SATIR_BUTON_HUCRE}>
<button
type="button"
aria-expanded={acikMi}
aria-controls={`rapor-satir-${t.sira}`}
onClick={(e) => {
e.stopPropagation();
tiklandi(t.sira);
}}
className="inline-flex size-8 min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={`inline-flex ${SATIR_DUGME_BOYU} cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 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.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
>
<ChevronDown
className={`size-4 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
@@ -296,24 +316,25 @@ export function RaporListesi({
</TableRow>
{acikMi ? (
<TableRow className="hover:bg-transparent">
<TableRow id={`rapor-satir-${t.sira}`} className="hover:bg-transparent">
<TableCell
colSpan={SUTUN_SAYISI}
className="whitespace-normal bg-slate-50/60 px-4 py-4"
className="whitespace-normal bg-slate-50 px-4 py-5 sm:px-5"
>
{analizKilitli ? (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
<span className="font-medium">Kişisel analiz pakette</span>
{p?.unitur ? <span>· {p.unitur}</span> : null}
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-slate-500">
<Lock className="size-4" aria-hidden />
<span className="font-semibold text-slate-700">
Kişisel analiz pakette
</span>
</div>
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-3">
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Lock className="size-3.5" aria-hidden />
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-4">
<p className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
<Lock className="size-4" aria-hidden />
Yapay Zeka karar desteği
</p>
<p className="mt-1 text-sm text-slate-600">
<p className="mt-1 text-base leading-relaxed text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel
yerleşme riskini paketle gör.
</p>
@@ -333,9 +354,9 @@ export function RaporListesi({
<button
type="button"
onClick={() => onHaritadaGor(p.il, t.sira)}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
className="mt-2 inline-flex min-h-11 cursor-pointer items-center gap-1.5 text-sm font-semibold text-primary transition-colors duration-150 hover:text-orange-500"
>
<MapPin className="size-3.5" aria-hidden />
<MapPin className="size-4" aria-hidden />
Haritada gör
</button>
) : null}
@@ -349,13 +370,13 @@ export function RaporListesi({
return (
<div>
{kilitIdx < rapor.tercihler.length ? (
<p className="text-right text-xs font-medium text-emerald-700">
<p className="text-center text-sm font-semibold text-emerald-700 sm:text-right">
Programlar ve ham veriler ücretsiz
</p>
) : null}
{rapor.kapsam?.uyarlandi ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-relaxed text-amber-900">
Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "}
{rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "}
dengeli · {rapor.kapsam.dagilim.garanti} güvenli olarak kuruldu. Daha
@@ -364,27 +385,31 @@ export function RaporListesi({
) : null}
{tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-relaxed text-amber-900">
Listenin {rapor.tercihler.length} tercihi de tek şehirde (
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
{trBaslikDuzeni(tekIl)}). Boşta kalma riskini azaltmak için
birkaç farklı şehir eklemeyi düşünebilirsin.
</p>
) : null}
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8 pl-4 pr-0">#</TableHead>
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
<TableRow className="hover:bg-transparent">
<TableHead className={`w-12 pl-4 pr-0 sm:pl-5 ${SATIR_BASLIK}`}>
#
</TableHead>
<TableHead className={`pl-4 ${SATIR_BASLIK}`}>Program</TableHead>
<TableHead className={`hidden text-right lg:table-cell ${SATIR_BASLIK}`}>
Son {yilSayisi} yıl
</TableHead>
<TableHead>
<span className="hidden sm:inline">Yapay Zeka </span>Risk
<TableHead className={`hidden sm:table-cell ${SATIR_BASLIK}`}>
Yapay Zeka riski
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-12 pr-4 text-right">
<TableHead className={`hidden text-right sm:table-cell ${SATIR_BASLIK}`}>
Taban sıra
</TableHead>
<TableHead className={SATIR_BUTON_BASLIK}>
<span className="sr-only">Yapay Zeka değerlendirmesi</span>
</TableHead>
</TableRow>

View File

@@ -122,7 +122,7 @@ export async function RaporYazdir() {
</tbody>
</table>
<footer className="mt-6 border-t border-slate-300 pt-3 text-[10px] leading-relaxed text-slate-500">
<footer className="mt-6 border-t border-slate-300 pt-3 text-xs leading-relaxed text-slate-500">
<p>
Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi ({veriAraligi}{" "}
taban sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak

View File

@@ -24,7 +24,14 @@ import {
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay, siraKovasi } from "@/lib/analitik";
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import { trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
import {
SATIR_ALT_AD,
SATIR_AD_DUZ,
SATIR_CIP,
SATIR_GRUP,
SATIR_META,
} from "@/components/liste-satiri";
import { listemUretHref, PROFIL_DEGISTI_EVENT } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
import type { TadimlikSatir, TadimlikTip } from "@/lib/tadimlik-havuzu";
@@ -120,14 +127,11 @@ export function TadimlikSatiri({
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`;
return (
// first:mt-0 — kayıtlı listesi olan ziyaretçide /sonuc tadımlığı haritanın
// ve tablonun ALTINA koyar (sonuc/page.tsx), orada bölüm ritmini veren
// mt-16'yı alır. Liste yoksa tadımlık main'in ilk DOM çocuğudur (JSX
// yorumları düğüm üretmez) ve main'in py-12'si tek başına yeterli üst
// boşluğu verir.
// /sonuc'ta artık ilk öğe sonuç başlığı (h1); tadımlık onun altında
// (listesi olanda haritanın ve tablonun altında) mt-12 ile durur.
// Tadımlığın render edilmediği durumlarda (girişli kullanıcı, boş havuz)
// bu bileşen hiç dönmediği için kural ilk gerçek bölüme geçer.
<section ref={gorunumRef} className="mt-16 min-w-0 first:mt-0">
// bu bileşen hiç dönmez.
<section ref={gorunumRef} className="mt-12 min-w-0">
<div className="flex flex-col items-center text-center">
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
@@ -147,40 +151,36 @@ export function TadimlikSatiri({
</div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
{/* Rapor listesinin satırıyla aynı düzen (§4.15): ad sarar,
üniversite adı altında, taban sıra sağda iri. */}
<div className="flex items-start gap-3">
<UniLogo ad={p.universite} boy="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{p.isim}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
) : null}
<span className="truncate">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
<span className={SATIR_AD_DUZ}>{p.isim}</span>
<span className={SATIR_GRUP}>
<span className={SATIR_ALT_AD}>
{trBaslikDuzeni(p.universite)}
</span>
<span className={SATIR_META}>
{p.unitur ? (
<span className={SATIR_CIP}>{devlet ? "Devlet" : "Vakıf"}</span>
) : null}
{p.il ? (
<span className="shrink-0">{trBaslikDuzeni(p.il)}</span>
) : null}
</span>
</span>
</span>
<span className="hidden text-right text-xs md:inline-flex">
<span className="hidden text-right md:inline-flex">
<ProgramSiraGecmisi program={seri} />
</span>
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
<span className="inline-flex shrink-0 items-center gap-1.5 text-base font-semibold tabular-nums text-slate-900">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</div>
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
<div className="mt-4 rounded-xl bg-slate-50 px-4 py-5">
<RaporSatirDetayi
dilim={satir.dilim}
risk={risk}
@@ -201,7 +201,7 @@ export function TadimlikSatiri({
</p>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link
href={girisUrl}

View File

@@ -215,7 +215,7 @@ export function TercihDegistiModali({
key={satir.alan}
className={i > 0 ? "border-t border-slate-200" : undefined}
>
<dt className="bg-slate-50 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
<dt className="bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-700">
{satir.etiket}
</dt>
<dd className="grid grid-cols-2 divide-x divide-slate-200">

View File

@@ -29,6 +29,7 @@ import {
import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik";
import { cn } from "@/lib/utils";
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas";
@@ -61,13 +62,35 @@ function turEtiketi(tur: string) {
}
/** "Sayısal · 100.000. sıra" — kayıtlı profilin kısa özeti. */
function KayitliSira({ sira, tur }: { sira: number; tur: string }) {
function KayitliSira({
sira,
tur,
koyu = false,
}: {
sira: number;
tur: string;
/** Koyu funnel yüzeyinde (PikselCtaYuzeyi) */
koyu?: boolean;
}) {
return (
<span className="inline-flex items-center gap-1.5 text-sm text-slate-600">
<Trophy className="size-4 shrink-0 text-primary" aria-hidden />
<span
className={cn(
"inline-flex items-center gap-1.5 text-sm",
koyu ? "text-slate-300" : "text-slate-600",
)}
>
<Trophy
className={cn("size-4 shrink-0", koyu ? "text-orange-500" : "text-primary")}
aria-hidden
/>
<span>
Sıralaman kayıtlı:{" "}
<strong className="font-semibold tabular-nums text-slate-900">
<strong
className={cn(
"font-semibold tabular-nums",
koyu ? "text-white" : "text-slate-900",
)}
>
{sira.toLocaleString("tr-TR")}.
</strong>{" "}
sıra · {turEtiketi(tur)}
@@ -424,54 +447,58 @@ export function CtaSiraForm({
);
}
// Kart varyantı sayfa sonundaki funnel CTA'sıdır: arama kartının koyu
// yüzeyi + 24'lük piksel liste (DESIGN.md §10.8).
if (profil) {
return (
<section className="flex flex-col items-center rounded-2xl border border-primary/20 bg-primary/5 px-6 py-8 text-center sm:px-10 sm:py-10">
<h2 className="font-heading text-xl font-bold text-balance text-slate-900 sm:text-2xl">
<PikselCtaYuzeyi metinOfseti={5}>
<h2 className="font-heading text-2xl font-bold text-balance sm:text-3xl">
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
</h2>
<div className="mt-3">
<KayitliSira sira={profil.sira} tur={profil.tur} />
<KayitliSira sira={profil.sira} tur={profil.tur} koyu />
</div>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
<p className="mt-2 max-w-xl text-base leading-relaxed text-slate-300">
Gerçek YÖK Atlas verisiyle hayal, dengeli ve güvenli dilimlerindeki
programların hazır.
</p>
<PagePixelDivider seed={67} className="mt-6" />
<Button asChild size="lg" className={cn(PROFILLI_CTA_SINIF, "mt-6")}>
<Button
asChild
size="lg"
className={cn(PROFILLI_CTA_SINIF, "mt-6 w-full active:scale-[0.97] sm:w-auto")}
>
<Link href={sonucHref()}>
Sıralamana uygun programları gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</section>
</PikselCtaYuzeyi>
);
}
return (
<section
ref={formRef}
className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8"
>
<h2 className="font-heading text-xl font-bold text-slate-900">
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{aciklama ??
"YKS başarı sıralamanı gir; gerçek YÖK Atlas verisiyle sıralamana uygun hayal, dengeli ve güvenli dilimlerindeki programları anında listele. Ücretsiz, üyelik gerekmez."}
</p>
<Button
type="button"
onClick={() => setAcik(true)}
onPointerEnter={preloadSihirbazAdimlar}
onFocus={preloadSihirbazAdimlar}
className="mt-4 h-11 cursor-pointer bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
>
Sıralamanı gir, programları gör
<ArrowRight className="size-4" aria-hidden />
</Button>
<div ref={formRef}>
<PikselCtaYuzeyi metinOfseti={9}>
<h2 className="font-heading text-2xl font-bold text-balance sm:text-3xl">
{baslik ?? "Sıralamana yakın programlar hangileri? Saniyede gör."}
</h2>
<p className="mt-3 max-w-xl text-base leading-relaxed text-slate-300">
{aciklama ??
"YKS başarı sıralamanı gir; gerçek YÖK Atlas verisiyle sıralamana uygun hayal, dengeli ve güvenli dilimlerindeki programları anında listele. Ücretsiz, üyelik gerekmez."}
</p>
<Button
type="button"
size="lg"
onClick={() => setAcik(true)}
onPointerEnter={preloadSihirbazAdimlar}
onFocus={preloadSihirbazAdimlar}
className="mt-6 h-12 w-full cursor-pointer bg-orange-500 px-6 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97] sm:w-auto"
>
Sıralamanı gir, programları gör
<ArrowRight className="size-4" aria-hidden />
</Button>
</PikselCtaYuzeyi>
{cekmece}
</section>
</div>
);
}

View File

@@ -28,13 +28,18 @@ export function Cip({
onClick,
children,
kucuk = false,
className = "",
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
kucuk?: boolean;
className?: string;
}) {
const boyut = kucuk ? "px-2.5 py-1 text-xs" : "px-3.5 py-2 text-sm";
// Şişko ve sade (DESIGN.md §1.1): çip 44 px dokunma hedefi; il
// çipleri (kucuk) çok sayıda olduğu için 40 px. Hover §2.1 tek turuncu.
const boyut = kucuk ? "h-10 px-3.5 text-sm" : "h-11 px-4 text-sm";
const temel = `inline-flex cursor-pointer items-center rounded-full border font-semibold transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] ${boyut} ${className}`;
return (
<button
type="button"
@@ -42,8 +47,8 @@ export function Cip({
aria-pressed={secili}
className={
secili
? `cursor-pointer rounded-full border border-primary bg-primary font-medium text-white transition-colors duration-200 ${boyut}`
: `cursor-pointer rounded-full border border-slate-200 bg-white text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 ${boyut}`
? `${temel} border-primary bg-primary text-white`
: `${temel} border-slate-200 bg-white text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500`
}
>
{children}
@@ -51,6 +56,8 @@ export function Cip({
);
}
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
// Haritada karşılığı olan iller (KKTC/yurtdışı kampüsler çip olarak kalır)
const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name)));
@@ -232,22 +239,40 @@ export function SihirbazAdimlar({
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className={
i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-[width,background-color] duration-200 ease-out"
: i < adim
? "h-1.5 w-3 rounded-full bg-primary/50 transition-[width,background-color] duration-200 ease-out"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-[width,background-color] duration-200 ease-out"
}
/>
))}
<span className="ml-2 text-xs font-medium text-slate-400">
Adım {adim + 1}/3
</span>
{/* Adım göstergesi (DESIGN.md §10): üç parçalı çubuk + adların
kendisi; öğrenci nerede olduğunu ve ne kaldığını görür. Değişim
ekran okuyucuya duyurulur (WCAG 4.1.3). */}
<div>
<ol className="grid grid-cols-3 gap-2" aria-label="Sihirbaz adımları">
{ADIM_ADLARI.map((ad, i) => (
<li key={ad} className="flex flex-col gap-1.5">
{/* Dolum transform ile (yalnız transform/opaklık canlandırılır);
soldan dolar, güçlü ease-out 300 ms. */}
<span className="h-1.5 overflow-hidden rounded-full bg-slate-200" aria-hidden>
<span
className={`block h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
i <= adim ? "scale-x-100" : "scale-x-0"
}`}
/>
</span>
<span
className={`text-sm ${
i === adim
? "font-semibold text-slate-900"
: i < adim
? "font-medium text-slate-600"
: "text-slate-400"
}`}
aria-current={i === adim ? "step" : undefined}
>
{ad}
</span>
</li>
))}
</ol>
<p role="status" className="sr-only">
Adım {adim + 1} / 3: {ADIM_ADLARI[adim]}
</p>
</div>
<TransitionPanel
@@ -293,7 +318,7 @@ export function SihirbazAdimlar({
}
>
<div ref={panelRef} className="px-px pb-2">
<h3 className="font-heading text-lg font-bold">
<h3 className="font-heading text-xl font-bold text-slate-900">
Hangi alanlara ilgi duyuyorsun?
</h3>
<p className="mt-1 text-sm text-slate-500">
@@ -307,20 +332,20 @@ export function SihirbazAdimlar({
onClick={() => listeDegistir(kategoriler, setKategoriler, k.ad)}
>
{k.ad}
<span className="ml-1.5 text-xs opacity-70">{k.adet}</span>
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</div>
<div ref={panelRef}>
<h3 className="font-heading text-lg font-bold">
<h3 className="font-heading text-xl font-bold text-slate-900">
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.
</p>
<div className="mt-4 flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-2 transition-colors focus-within:border-slate-300">
<div className="mt-4 flex h-11 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 transition-colors focus-within:border-slate-300">
<Search className="size-4 shrink-0 text-slate-400" aria-hidden />
<input
type="text"
@@ -329,14 +354,14 @@ export function SihirbazAdimlar({
placeholder="İl ara..."
aria-label="İl ara"
autoComplete="off"
className="min-w-0 flex-1 bg-transparent text-sm text-slate-950 outline-none placeholder:text-slate-400"
className="min-w-0 flex-1 bg-transparent text-base text-slate-950 outline-none placeholder:text-slate-400"
/>
{ilArama ? (
<button
type="button"
onClick={() => setIlArama("")}
aria-label="Aramayı temizle"
className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
className="-mr-2 flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<X className="size-3.5" aria-hidden />
</button>
@@ -350,7 +375,7 @@ export function SihirbazAdimlar({
yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
devre dışı bırakır. */}
<div
className={`mt-3 flex max-h-56 flex-wrap gap-1.5 overflow-y-auto sm:max-h-none sm:overflow-visible ${
className={`mt-3 flex max-h-64 flex-wrap gap-2 overflow-y-auto sm:max-h-none sm:overflow-visible ${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -362,14 +387,14 @@ export function SihirbazAdimlar({
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{trBaslikDuzeni(i.il)}
<span className="ml-1 text-[10px] opacity-70">{i.adet}</span>
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
</Cip>
))}
{ilListesiKirpik ? (
<button
type="button"
onClick={() => setTumIllerAcik(true)}
className="cursor-pointer rounded-full border border-dashed border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors duration-200 hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700"
className="inline-flex h-10 cursor-pointer items-center rounded-full border border-dashed border-slate-300 bg-white px-3.5 text-sm font-semibold text-slate-500 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]"
>
+{gorunenTurkiyeIlleri.length - IL_ON_IZLEME} il daha
</button>
@@ -381,7 +406,7 @@ export function SihirbazAdimlar({
<button
type="button"
onClick={() => setTumIllerAcik(false)}
className="cursor-pointer rounded-full border border-dashed border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors duration-200 hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700"
className="inline-flex h-10 cursor-pointer items-center rounded-full border border-dashed border-slate-300 bg-white px-3.5 text-sm font-semibold text-slate-500 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]"
>
Daha az göster
</button>
@@ -398,11 +423,11 @@ export function SihirbazAdimlar({
{gorunenDisIller.length > 0 ? (
<div className="mt-4">
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
<p className="mb-2 text-sm font-semibold text-slate-700">
KKTC & Yurt Dışı
</p>
<div
className={`flex flex-wrap gap-1.5 ${
className={`flex flex-wrap gap-2 ${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -414,7 +439,7 @@ export function SihirbazAdimlar({
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{trBaslikDuzeni(i.il)}
<span className="ml-1 text-[10px] opacity-70">{i.adet}</span>
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
</Cip>
))}
</div>
@@ -423,7 +448,7 @@ export function SihirbazAdimlar({
{iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400">
<span className="text-sm font-semibold text-slate-700">
Seçtiklerin:
</span>
{iller.map((il) => (
@@ -431,10 +456,10 @@ export function SihirbazAdimlar({
key={il}
type="button"
onClick={() => listeDegistir(iller, setIller, il)}
className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-orange-600"
className="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-primary px-3.5 text-sm font-semibold text-white transition-[background-color,transform] duration-150 hover:bg-primary/90 active:scale-[0.97]"
>
{trBaslikDuzeni(il)}
<X className="size-3" aria-hidden />
<X className="size-4" aria-hidden />
</button>
))}
</div>
@@ -449,9 +474,9 @@ export function SihirbazAdimlar({
/>
</div>
</div>
<div ref={panelRef} className="space-y-5">
<div ref={panelRef} className="space-y-4">
<div>
<h3 className="font-heading text-lg font-bold">
<h3 className="font-heading text-xl font-bold text-slate-900">
Üniversite tipi & öncelikler
</h3>
<p className="mt-1 text-sm text-slate-500">
@@ -459,7 +484,7 @@ export function SihirbazAdimlar({
</p>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
<p className="mb-2 text-sm font-semibold text-slate-700">
Üniversite tipi
</p>
<div
@@ -478,7 +503,7 @@ export function SihirbazAdimlar({
}
>
{UNIVERSITE_TIPI_ETIKET[u.grup]}
<span className="ml-1.5 text-xs opacity-70">{u.adet}</span>
<span className="ml-1.5 text-xs font-medium opacity-70">{u.adet}</span>
</Cip>
))}
<Cip
@@ -490,13 +515,16 @@ export function SihirbazAdimlar({
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
<p className="mb-2 text-sm font-semibold text-slate-700">
Senin için önemli olan
</p>
<div className="flex flex-wrap gap-2">
{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda
yedi çip 44 px'le pencereyi 812 px'in dışına itiyordu. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{ONCELIKLER.map((o) => (
<Cip
key={o}
className="h-auto min-h-11 justify-center px-3 py-1.5 text-center leading-tight sm:h-11 sm:px-4 sm:py-0"
secili={oncelikler.includes(o)}
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
>
@@ -504,7 +532,7 @@ export function SihirbazAdimlar({
</Cip>
))}
</div>
<p className="mt-2 text-xs text-slate-500">
<p className="mt-2 text-sm text-slate-500">
Öncelikler yapay zekânın gerekçelerine ve program seçimine yön
verir; listeyi daraltmaz.
</p>
@@ -512,13 +540,13 @@ export function SihirbazAdimlar({
</div>
</TransitionPanel>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<div className="flex items-center justify-between gap-2 border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => adimaGit(adim - 1)}
disabled={adim === 0}
className={adim === 0 ? "invisible" : "cursor-pointer"}
className={adim === 0 ? "invisible" : "h-11 cursor-pointer px-3"}
>
<ArrowLeft className="size-4" aria-hidden />
Geri
@@ -528,7 +556,7 @@ export function SihirbazAdimlar({
type="button"
onClick={() => adimaGit(adim + 1)}
disabled={!adimGecerli}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
Devam
<ArrowRight className="size-4" aria-hidden />
@@ -546,7 +574,7 @@ export function SihirbazAdimlar({
onTamamla({ kategoriler, iller, universiteTipi, oncelikler });
}}
disabled={kategoriler.length === 0}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
{sonButonEtiketi}
<ArrowRight className="size-4" aria-hidden />

View File

@@ -7,7 +7,7 @@ import { ArrowRight } from "lucide-react";
import { animate } from "motion/mini";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { PixelField } from "@/components/pixel-decor";
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay, olayBirKez, siraKovasi } from "@/lib/analitik";
import type { SihirbazFacetleri } from "@/types/yokatlas";
@@ -279,70 +279,57 @@ export function SihirbazBolumu({
</Button>
</section>
) : girisBekliyor ? (
<section
ref={hazirPanelRef}
className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12"
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır — Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
}
<div ref={hazirPanelRef} className="mt-16 min-w-0">
<PikselCtaYuzeyi metinOfseti={3}>
<h2 className="font-heading text-2xl font-bold text-balance sm:text-3xl">
Seçimlerin hazır — Yapay Zeka listen bir adım uzakta
</h2>
<p className="mt-3 max-w-xl text-base leading-relaxed text-slate-300">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3 kredi,
kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97] sm:w-auto sm:px-8"
>
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
}
>
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="mt-3 text-sm text-slate-400">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</PikselCtaYuzeyi>
</div>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
<PikselCtaYuzeyi className="mt-16 min-w-0" metinOfseti={11}>
<h2 className="font-heading text-2xl font-bold text-balance sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
<p className="mt-3 max-w-xl text-base leading-relaxed text-slate-300">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve güvenli dilimlerine dağıtılmış 24 tercihi — her satırı
yerleşme riski ve gerekçesiyle — senin yerine dizer.
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli ve
güvenli dilimlerine dağıtılmış 24 tercihi — her satırı yerleşme
riski ve gerekçesiyle — senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97] sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
</PikselCtaYuzeyi>
)}
{monteEdildi ? (

View File

@@ -7,7 +7,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Trophy } from "lucide-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { profilCerezineYaz } from "@/features/sihirbaz/sihirbaz-profil";
@@ -34,19 +34,21 @@ export function SiraGerekli() {
if (!profilYuklendi || profil) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor…</p>
<p role="status" className="text-base text-slate-500">
Sıralaman kontrol ediliyor…
</p>
</main>
);
}
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<h1 className="font-heading text-2xl font-bold">
Geçerli bir sıralama gerekli
<h1 className="font-heading text-3xl font-bold text-balance text-slate-900 sm:text-4xl">
Önce sıralamanı gir
</h1>
<p className="mt-3 text-slate-600">
Listeni kurabilmek için YKS başarı sıralamanı gir. Kaydettiğin sıra tüm
uygulamada kullanılır.
<p className="mt-4 max-w-md text-base leading-relaxed text-slate-600 sm:text-lg">
YKS başarı sıralamanı girince hangi programların sana açık olduğunu
hemen görürsün. Bir kez girmen yeter; her sayfada kullanılır.
</p>
<PagePixelDivider seed={59} className="mx-auto mt-6" />
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
@@ -56,12 +58,18 @@ export function SiraGerekli() {
const { href } = sonucaGitIste();
if (href) router.push(href);
}}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
size="lg"
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
<Trophy className="size-4" aria-hidden />
Sıralamamı gir
<ArrowRight className="size-4" aria-hidden />
</Button>
<Button asChild variant="outline" className="cursor-pointer">
<Button
asChild
size="lg"
variant="outline"
className="cursor-pointer px-6 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
>
<Link href="/">
<ArrowLeft className="size-4" aria-hidden />
Ana sayfaya dön

View File

@@ -0,0 +1,36 @@
/**
* CTA yüzeylerinde yazılıp silinen kısa satırlar (arama penceresindeki
* kart ve PikselCtaYuzeyi). "use client" yok: sunucu da okuyabilsin (§9.3).
*/
export const FUNNEL_METINLERI: readonly string[] = [
"Sıralamana en uygun listeyi birlikte kuralım.",
"Geleceğin 24 satırlık bir plana sığar.",
"Gerçek YÖK Atlas verisiyle güvenle seç.",
"Her tercihinin bir nedeni olsun.",
"Riskleri biz hesaplayalım, kararı sen ver.",
"Sana uygun bölümler bir adım uzağında.",
"Dengeli bir liste, rahat bir tercih dönemi.",
"Tercihlerini şansa bırakma.",
"Doğru strateji, doğru yerleşme.",
"Hayalindeki bölüm için doğru plan.",
"Verilerle konuşan bir tercih listesi.",
"Üç adımda sana özel 24 tercih.",
"Tercih dönemini stressiz geçir.",
"Geçen yılın verisi, senin avantajın.",
"Güvenli ve iddialı tercihleri dengele.",
"Sıralaman ne söylüyor? Birlikte bakalım.",
"Her satırı senin için hesaplıyoruz.",
"Akıllı tercih, içi rahat bir bekleyiş.",
"Yerleşme riskini görerek seç.",
"Sana özel liste birkaç dakikada hazır.",
"İlgi alanların ve şehrin, tek listede.",
"Boş kalan tercih, kaçan fırsattır.",
"Uzman işi bir liste, senin onayınla.",
"Kararını veriye dayandır.",
"Doğru tercih, doğru bilgiyle başlar.",
"Listeni bugün kur, için rahat olsun.",
"Yerleşme riskin renklerle önünde.",
"En önemli 24 kararını birlikte verelim.",
"Senin sıralaman, sana özel plan.",
"İyi bir liste, iyi bir başlangıçtır.",
];