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
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:
106
.claude/skills/better-ui/SKILL.md
Normal file
106
.claude/skills/better-ui/SKILL.md
Normal 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.
|
||||
146
docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md
Normal file
146
docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
120
src/app/page.tsx
120
src/app/page.tsx
@@ -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"
|
||||
<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) => (
|
||||
<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}
|
||||
</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>
|
||||
))}
|
||||
</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">
|
||||
<TableHead className="w-[40%]">Özellik</TableHead>
|
||||
<TableHead className="text-center">
|
||||
Ücretsiz robotlar ve sohbet botları
|
||||
<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="text-center">
|
||||
<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="text-center font-heading font-bold text-primary">
|
||||
<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}>
|
||||
<TableCell className="font-medium">
|
||||
<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="text-center">
|
||||
<TableCell className="whitespace-normal py-4 text-center">
|
||||
<ComparisonCell value={row.robot} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="whitespace-normal py-4 text-center">
|
||||
<ComparisonCell value={row.danisman} />
|
||||
</TableCell>
|
||||
<TableCell className="bg-primary/5 text-center">
|
||||
<ComparisonCell value={row.tercihai} />
|
||||
<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">
|
||||
{/* 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 />
|
||||
</div>
|
||||
</div>
|
||||
</PikselCtaYuzeyi>
|
||||
</Reveal>
|
||||
</Parallax>
|
||||
</section>
|
||||
|
||||
@@ -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="transition-colors hover:text-orange-500 hover:underline"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
58
src/components/liste-satiri.ts
Normal file
58
src/components/liste-satiri.ts
Normal 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";
|
||||
58
src/components/ozet-kart.tsx
Normal file
58
src/components/ozet-kart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/piksel-cta-yuzeyi.tsx
Normal file
77
src/components/piksel-cta-yuzeyi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/components/piksel-liste.tsx
Normal file
191
src/components/piksel-liste.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/risk-etiketi.tsx
Normal file
32
src/components/risk-etiketi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,9 +215,34 @@ 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">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
@@ -193,62 +254,87 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
|
||||
<nav
|
||||
aria-label="İçerik yolu"
|
||||
className="mb-5 flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground"
|
||||
className="flex flex-wrap items-center justify-center gap-1.5 text-sm text-slate-500 lg:justify-start"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="transition-colors hover:text-orange-500 hover:underline"
|
||||
>
|
||||
Ana sayfa
|
||||
Ana Sayfa
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
|
||||
<Link
|
||||
href="/bolumler"
|
||||
className="transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="transition-colors hover:text-orange-500 hover:underline"
|
||||
>
|
||||
Bölümler
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
|
||||
{ilkSayfa ? (
|
||||
<span className="text-foreground">{bolum.ad}</span>
|
||||
<span className="text-slate-700">{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"
|
||||
className="transition-colors hover:text-orange-500 hover:underline"
|
||||
>
|
||||
{bolum.ad}
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
<span className="text-foreground">Sayfa {sayfa}</span>
|
||||
<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 */}
|
||||
{/* 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} />
|
||||
<header className="border-b pb-7 sm:pb-9">
|
||||
<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,34 +343,78 @@ 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">
|
||||
{/* 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>
|
||||
|
||||
<section aria-labelledby="universite-programlari" className="mt-8">
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
{/* 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}
|
||||
|
||||
<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-xl font-bold text-foreground sm:text-2xl"
|
||||
className="font-heading text-2xl font-bold text-slate-900 sm:text-3xl"
|
||||
>
|
||||
Üniversite programları
|
||||
</h2>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{sayi(tumProgramlar.length)} program
|
||||
</span>
|
||||
<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,
|
||||
@@ -294,6 +424,7 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
tabanYol={`/bolum/${slug}`}
|
||||
sayfa={sayfa}
|
||||
/>
|
||||
</div>
|
||||
<VeriKaynagi className="mt-6" />
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
<span className={SATIR_GRUP}>
|
||||
{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"
|
||||
className={SATIR_AD}
|
||||
>
|
||||
{uniAd}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
<span className={SATIR_AD_DUZ}>
|
||||
{uniAd}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
|
||||
<span className={SATIR_META}>
|
||||
{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_CIP}>
|
||||
{UNITUR_ETIKET[p.unitur]?.cip ??
|
||||
trBaslikDuzeni(p.unitur)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.il ? trBaslikDuzeni(p.il) : null}
|
||||
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
|
||||
{p.il ? (
|
||||
<span className="shrink-0">
|
||||
{trBaslikDuzeni(p.il)}
|
||||
</span>
|
||||
) : null}
|
||||
{p.sure ? (
|
||||
<span className="shrink-0 text-slate-400">
|
||||
· {p.sure} yıl
|
||||
</span>
|
||||
<span className="shrink-0">{p.sure} yıl</span>
|
||||
) : null}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
<TableCell className={SATIR_AD_HUCRE}>
|
||||
<span className={SATIR_GRUP}>
|
||||
{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"
|
||||
className={SATIR_AD}
|
||||
>
|
||||
{p.isim.trim()}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block text-base font-semibold leading-snug text-slate-900">
|
||||
<span className={SATIR_AD_DUZ}>
|
||||
{p.isim.trim()}
|
||||
</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">
|
||||
<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-1.5 flex items-center gap-1.5 text-sm text-slate-500 sm:hidden">
|
||||
</span>
|
||||
<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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
{/* 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={temizle}
|
||||
onClick={() =>
|
||||
geriAlinabilirCikar(
|
||||
liste.map((t) => t.id),
|
||||
`Listen temizlendi · ${liste.length} program çıkarıldı`,
|
||||
)
|
||||
}
|
||||
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"
|
||||
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="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<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="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
|
||||
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"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-primary"
|
||||
animate={{ width: `${doluluk}%` }}
|
||||
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
|
||||
<PikselListe
|
||||
gercek={liste.map((t) =>
|
||||
t.risk === "riskli"
|
||||
? "hayal"
|
||||
: t.risk === "az-riskli"
|
||||
? "dengeli"
|
||||
: t.risk === "guvenli"
|
||||
? "guvenli"
|
||||
: "belirsiz",
|
||||
)}
|
||||
sutun={12}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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>{" "}
|
||||
</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>
|
||||
</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="shrink-0 text-xs tabular-nums text-slate-500">
|
||||
<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>
|
||||
) : 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}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
55
src/features/liste/components/liste-durumu-satiri.tsx
Normal file
55
src/features/liste/components/liste-durumu-satiri.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
<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-3.5 ${aktif ? d.renkAktif : d.renk}`}
|
||||
className={`size-4 ${aktif ? d.renkAktif : d.renk}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{d.etiket}
|
||||
<span className="text-xs tabular-nums text-slate-400">
|
||||
</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>
|
||||
<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 text-slate-400">· {p.sure} yıl</span>
|
||||
<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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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."}
|
||||
{/* 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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
<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}
|
||||
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
<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 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>
|
||||
) : (
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 className={SATIR_AD_DUZ}>{p.isim}</span>
|
||||
<span className={SATIR_GRUP}>
|
||||
<span className={SATIR_ALT_AD}>
|
||||
{trBaslikDuzeni(p.universite)}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<span className={SATIR_META}>
|
||||
{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>
|
||||
<span className={SATIR_CIP}>{devlet ? "Devlet" : "Vakıf"}</span>
|
||||
) : null}
|
||||
{p.il ? (
|
||||
<span className="shrink-0">{trBaslikDuzeni(p.il)}</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.universite}
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
<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-2 text-sm leading-6 text-slate-600">
|
||||
<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-4 h-11 cursor-pointer bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
{/* 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
|
||||
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"
|
||||
}
|
||||
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 className="ml-2 text-xs font-medium text-slate-400">
|
||||
Adım {adim + 1}/3
|
||||
</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 />
|
||||
|
||||
@@ -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,29 +279,21 @@ 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">
|
||||
<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="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">
|
||||
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).
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
@@ -313,36 +305,31 @@ export function SihirbazBolumu({
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="relative mt-3 text-xs text-white/70">
|
||||
<p className="mt-3 text-sm text-slate-400">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<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} />
|
||||
</PikselCtaYuzeyi>
|
||||
</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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
36
src/lib/funnel-metinleri.ts
Normal file
36
src/lib/funnel-metinleri.ts
Normal 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.",
|
||||
];
|
||||
Reference in New Issue
Block a user