Compare commits

2 Commits

Author SHA1 Message Date
bilalgursen
40a1c8ef8e Dev paneli Apple hareket diliyle yenilendi; animasyon ve UX düzeltmeleri
All checks were successful
Deploy / deploy (push) Successful in 9m29s
- Dev paneli: DEV çipinden layoutId morph ile büyüyüp küçülür (kritik sönümlü
  spring), yarı saydam malzeme (backdrop-blur + saturate), basışta anında
  scale geri bildirimi, Escape ile kapanış, reduced-motion/transparency desteği
- TypingAnimation ve TextLoop: prefers-reduced-motion'da sonsuz döngü durur,
  ilk metin sabit gösterilir
- kt-unblur: reduced-motion'da blur çözülmesi yerine kısa yumuşak belirme
- Sihirbaz: reduced-motion'da adım geçişinde height/x anında oturur;
  adım noktalarında transition-all yerine kapsamlı geçiş özellikleri
- Sihirbaz il adımındaki arama kutusu kaldırıldı; tüm iller program sayısına
  göre sıralı çip olarak listelenir
- /giris: spinner yerine kartın iskeletini gösteren route-level Skeleton

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:26:40 +03:00
bilalgursen
7288c926d0 UX/CRO denetimi uygulandı: değer-önce funnel, güven sayfaları, dev paneli
Kritik (K1-K6) sonrası kalan tüm maddeler:
- Değer önce: girişsiz kullanıcı sihirbaz bitince /giris yerine /sonuc'taki
  ücretsiz tabloya gider (?hazir=1); AI CTA'sı "5 deneme kredinle listeni
  kur — giriş yap" paneli + sabit alt karttan verilir, girişte otomatik üretim
- Ö1-Ö10: paywall kopyası, aranabilir il seçimi, "Seçtiklerim" adlandırması,
  gizlilik/koşullar/iletişim sayfaları, danışman özeti + uyarılar, revizyon
  kutusu, dilim özeti + tek şehir uyarısı, eksik veri rozetleri, magic link
  yeniden gönder, mobil sohbet kısayolu
- G1-G5: binlik ayraçlı input + inline hata, adım göstergesi, route-level
  loading/error/not-found, dürüst konumlandırma kartı, dürüst buton etiketleri
- Seçtiklerim butonu liste boşken gizli; ilk eklemede belirir
- Dev süperadmin paneli (yalnızca development): hızlı giriş, kredi/paket/rapor
  senaryoları, sahte ödeme dönüşü

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:10:57 +03:00
43 changed files with 1784 additions and 166 deletions

101
UX-DUZELTMELER.md Normal file
View File

@@ -0,0 +1,101 @@
# UX/CRO Kritik Düzeltmeler — Uygulama Takibi
UX denetiminden çıkan 🔴 kritik bulguların uygulama listesi.
Kaynak: 26 Temmuz 2026 tarihli UX/CRO denetimi.
## K1 — Ödeme sonrası teslimat
- [x] `/odeme/sonuc` başarı CTA'sı doğrudan `/listem`'e gitsin (ölü `/sonuc?acildi=1` yerine)
(sahte "paid" siparişle canlı doğrulandı: CTA → `/listem?acildi=1`)
- [x] `/listem` ödeme dönüşünde tek seferlik "kilit açıldı" deneyimi (banner + unblur)
- [x] Ücretli `/listem` başlık kopyası düzelsin (rozet "Paketin aktif", alt metin
"24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle…")
## K2 — Buton dürüstlüğü (giriş duvarı sürprizi)
- [x] Hero sihirbazı son butonu girişsiz kullanıcıda girişi açıkça söylesin
("Giriş yap ve listeni gör"; girişliyse "Listemi oluştur (3 kredi)")
- [x] Hero akışı oturumu açık kullanıcıyı `/giris`'ten sektirmeyip doğrudan `/sonuc`'a götürsün
- [x] `/sonuc` sihirbazında girişsiz son buton "Listemi gör" yerine dürüst etiket taşısın
- [x] Değer önce: hero sihirbazı biten girişsiz kullanıcı `/giris`'e değil doğrudan
`/sonuc`'taki ücretsiz tabloya gitsin; AI CTA'sı oradan "5 deneme kredinle
AI listeni kur — giriş yap" olarak verilsin (panel + sabit alt kart);
girişten dönüşte kayıtlı seçimlerle otomatik üretim başlasın
## K3 — Fiyat şeffaflığı
- [x] Landing hero mikro-kopyasına paket fiyatı (299 TL) eklensin
- [x] FAQ "Fiyatlandırma nasıl çalışıyor?" cevabına rakamlar yazılsın
## K4 — Puan türü seçici
- [x] Hero formuna SAY/EA/SÖZ/DİL/TYT segment kontrolü (varsayılan SAY)
## K5 — Ücretsiz kullanıcıya AI sohbeti
- [x] `/listem`'de paketi olmayan kullanıcıya kalan kredileriyle sohbet açılsın
(fiyat kartının altında; mesaj gönderimi + kredi düşümü canlı doğrulandı)
- [x] Kredi bitince composer yerinde paket CTA'sı görünsün (0 krediyle canlı doğrulandı)
## K6 — Ödeme/AI hata mesajları
- [x] iyzico/Anthropic yapılandırma hataları kullanıcıya ham sızmasın (log'a detay, kullanıcıya sade mesaj)
(iyzico yolu canlı doğrulandı: "Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı…")
- [x] `/paket` sayfası `?hata=token|siparis` parametresini okuyup banner göstersin
## Bonus
- [x] Hero rozetindeki "açıkk" yazım hatası düzeltilsin
---
# 🟠 Önemli
- [x] Ö1: `/listem` paywall ara kartı kopyası netleşsin (kilit ikonu + "aşağıdaki tüm
satırlar açık" çelişkisi giderilsin; CTA fiyatı söylesin)
(yeni kopya: "İlk 3 tercihte AI analizini gördün… Analizleri aç — 299 TL")
- [x] Ö2: İl seçimi mobilde aranabilir liste ile yapılabilsin; harita mobilde geri
çekilsin; harita illerine erişilebilir isim (aria-label) eklensin
(arama + çip birincil; harita ≥sm'de görsel destek; path'lerde aria-label/aria-pressed)
- [x] Ö3: "Listem" isim çakışması giderilsin (manuel liste "Seçtiklerim" olsun,
AI raporu /listem "Listem" kalsın)
- [x] Ö3b: Navbar'daki "Seçtiklerim" butonu liste boşken görünmesin — tabloya
ulaşmamış ziyaretçiye "0/24" gürültü; ilk program eklenince belirir,
liste boşalınca kaybolur (canlı doğrulandı)
- [x] Ö4: Güven sayfaları eklensin (gizlilik/KVKK, kullanım & iade koşulları,
iletişim) ve footer + giriş + paket sayfalarından bağlansın
(NOT: metinler MVP taslağı — yayın öncesi hukuki gözden geçirme gerekir)
- [x] Ö5: AI'ın ürettiği genel değerlendirme ve uyarılar /listem'de görünsün
(paketliye tam, paketsize kilitli önizleme — "Danışman özeti" kartı)
- [x] Ö6: Paketlinin gerçek revizyon akışı /listem'de çalışsın (geri bildirim
kutusu; başlıktaki "Revize et" oraya götürsün)
(`revizyon-kutusu.tsx``listeRevize` aksiyonu; kalan hak sayacıyla)
- [x] Ö7: Liste tepesine dilim özeti (hayal/dengeli/garanti dağılımı) ve tek
şehir uyarısı eklensin
- [x] Ö8: 2024/2025 verisi eksik satırda çelişkili trend rozeti gösterilmesin
(rozet iki yıl da doluysa çıkar; yoksa "Veri eksik")
- [x] Ö9: Magic link "gönderildi" ekranına yeniden gönder + e-postayı düzelt eklensin
(30 sn bekleme sayaçlı yeniden gönder + "E-postayı düzelt")
- [x] Ö10: Mobilde sohbete hızlı erişim (yüzen "Danışmana sor" kısayolu)
# 🛠 Dev araçları
- [x] Dev süperadmin paneli: yalnızca geliştirme ortamında sol altta "DEV"
butonu — anında giriş (magic link atlanır), kredi/paket/rapor senaryoları,
sahte "paid" siparişle ödeme başarı ekranı, tüm sayfalara hızlı bağlantılar
(`src/components/dev/dev-panel.tsx` + `src/lib/dev/actions.ts`;
layout NODE_ENV kontrolüyle monte eder, aksiyonlar sunucuda ayrıca korunur)
# 🟢 Güzel olur
- [x] G1: Sıralama girişinde canlı binlik ayracı + toast yerine input altı hata
- [x] G2: Sihirbaz adım göstergesi tamamlanan adımı işaretlesin; 1. adımda
"Geri" görünmesin
- [x] G3: Route-level loading / error / not-found boundary'leri eklensin
(kök loading/error/not-found + /sonuc ve /listem'e Skeleton'lı loading)
- [x] G4: Landing istatistik kartlarının yanında "yerleşme garantisi vermeyen
dürüst araç" konumlandırması cesur işlensin (koyu kart: "Yerleşme
garantisi vermiyoruz. Çünkü kimse veremez.")
- [x] G5: `sonButonEtiketi` şeffaflığı hero varyantına taşınsın (girişli:
"Listemi oluştur (3 kredi)"; girişsiz artık ücretsiz tabloya gittiği
için "Ücretsiz tablonu gör")

Binary file not shown.

View File

@@ -29,6 +29,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-use-measure": "^2.1.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"resend": "^6.18.0",

18
pnpm-lock.yaml generated
View File

@@ -62,6 +62,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.17)(react@19.2.4)
react-use-measure:
specifier: ^2.1.7
version: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
@@ -4720,6 +4723,15 @@ packages:
'@types/react':
optional: true
react-use-measure@2.1.7:
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
peerDependencies:
react: '>=16.13'
react-dom: '>=16.13'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
@@ -10237,6 +10249,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
optionalDependencies:
react-dom: 19.2.4(react@19.2.4)
react@19.2.4: {}
readable-stream@3.6.2:

28
public/yok-logo.svg Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 0.6.12 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="871" height="420" viewBox="75 82 871 420">
<path d="M0,0 L46,0 L54,8 L54,10 L56,10 L56,12 L58,12 L58,14 L60,14 L65,19 L76,30 L76,32 L78,32 L83,37 L96,50 L96,52 L100,54 L118,72 L118,74 L122,76 L158,112 L158,114 L160,114 L160,116 L164,118 L168,118 L178,108 L180,108 L182,104 L184,104 L186,100 L188,100 L188,98 L196,91 L217,70 L219,70 L220,67 L222,67 L222,65 L230,58 L252,36 L254,36 L254,34 L262,27 L287,2 L289,1 L334,1 L332,5 L314,23 L306,30 L286,50 L284,50 L282,54 L270,65 L247,88 L239,95 L218,116 L216,116 L214,120 L199,134 L190,143 L188,143 L186,147 L171,161 L150,182 L148,182 L148,184 L140,191 L111,220 L103,227 L82,248 L74,255 L46,283 L38,290 L14,314 L12,314 L12,316 L4,323 L-3,330 L-50,330 L-44,323 L-25,304 L-23,304 L-23,302 L-15,295 L2,278 L4,278 L4,276 L12,269 L30,251 L38,244 L58,224 L60,224 L60,222 L62,222 L62,220 L70,213 L81,202 L75,195 L61,181 L58,179 L58,177 L54,175 L22,143 L20,142 L20,140 L18,140 L18,138 L14,136 L-16,106 L-24,99 L-56,67 L-64,60 L-93,31 L-95,30 L-95,28 L-99,26 L-121,4 L-121,2 L-78,1 L-67,10 L-32,45 L-28,48 L-28,50 L-24,52 L8,84 L16,91 L46,121 L54,128 L85,159 L93,166 L102,175 L107,173 L115,165 L123,158 L132,150 L134,150 L134,148 L140,143 L138,139 L82,83 L80,82 L80,80 L78,80 L78,78 L76,78 L76,76 L74,76 L74,74 L70,72 L70,70 L68,70 L68,68 L66,68 L66,66 L62,64 L0,2 Z " fill="#e22126" transform="translate(196,82)"/>
<path d="M0,0 L46,0 L44,4 L14,34 L12,34 L12,36 L10,36 L10,38 L2,45 L-39,86 L-44,91 L-54,100 L-86,132 L-88,132 L-88,134 L-90,134 L-90,136 L-92,136 L-85,144 L-71,158 L-68,160 L-68,162 L-64,164 L-33,195 L-25,202 L5,232 L13,239 L37,263 L39,264 L39,266 L43,268 L70,295 L72,296 L72,298 L76,300 L104,328 L104,330 L61,330 L46,315 L44,314 L44,312 L40,310 L40,308 L36,306 L20,290 L12,283 L-8,263 L-10,262 L-10,260 L-14,258 L-35,237 L-43,230 L-62,211 L-64,210 L-64,208 L-66,208 L-66,206 L-68,206 L-68,204 L-72,202 L-92,182 L-100,175 L-114,161 L-119,163 L-134,178 L-142,185 L-153,196 L-147,203 L-134,216 L-127,224 L-111,240 L-111,242 L-109,242 L-102,250 L-80,272 L-73,280 L-56,297 L-49,305 L-32,322 L-26,330 L-68,330 L-78,320 L-86,313 L-118,281 L-126,274 L-155,245 L-163,238 L-179,222 L-184,224 L-184,226 L-192,233 L-213,254 L-221,261 L-246,286 L-254,293 L-281,320 L-289,327 L-292,330 L-338,330 L-332,323 L-305,296 L-297,289 L-270,262 L-268,262 L-268,260 L-266,260 L-266,258 L-258,251 L-229,222 L-221,215 L-206,200 L-204,200 L-202,196 L-194,188 L-192,188 L-192,186 L-184,179 L-157,152 L-149,145 L-121,117 L-113,110 L-83,80 L-75,73 L-50,48 L-48,48 L-48,46 L-40,39 L-9,8 L-7,8 L-7,6 Z " fill="#e22126" transform="translate(840,82)"/>
<path d="M0,0 L48,0 L48,2 L46,2 L46,4 L38,11 L14,35 L6,42 L-20,68 L-28,75 L-53,100 L-61,107 L-84,130 L-86,130 L-88,134 L-92,136 L-92,138 L-100,145 L-120,165 L-128,172 L-158,202 L-166,209 L-187,230 L-189,230 L-191,234 L-201,243 L-222,264 L-230,271 L-259,300 L-267,307 L-288,328 L-332,328 L-332,326 L-324,319 L-281,276 L-276,271 L-266,262 L-216,212 L-208,205 L-166,163 L-164,163 L-162,159 L-157,155 L-152,150 L-104,102 L-102,102 L-101,99 L-93,92 L-49,48 L-47,48 L-45,44 L-29,29 Z " fill="#e22126" transform="translate(598,84)"/>
<path d="M0,0 L47,0 L46,4 L36,13 L11,38 L9,38 L7,42 L5,42 L5,44 L-3,51 L-27,75 L-35,82 L-59,106 L-61,106 L-61,108 L-69,115 L-91,137 L-93,137 L-94,140 L-102,147 L-123,168 L-125,168 L-127,172 L-129,172 L-129,174 L-137,181 L-160,204 L-168,211 L-191,234 L-193,234 L-195,238 L-199,240 L-199,242 L-207,249 L-232,274 L-240,281 L-263,304 L-271,311 L-288,328 L-332,328 L-326,321 L-291,286 L-289,286 L-289,284 L-287,284 L-287,282 L-283,278 L-281,278 L-281,276 L-273,269 L-232,228 L-230,228 L-229,225 L-221,218 L-177,174 L-175,174 L-173,170 L-171,170 L-171,168 L-169,168 L-169,166 L-167,166 L-167,164 L-165,164 L-165,162 L-163,162 L-163,160 L-155,153 L-113,111 L-105,104 L-100,99 L-57,56 L-55,56 L-55,54 L-50,50 L-49,48 L-47,48 L-47,46 L-39,39 Z " fill="#e22126" transform="translate(721,84)"/>
<path d="M0,0 L5,0 L7,3 L14,3 L15,0 L20,1 L18,6 L13,9 L4,8 L0,4 Z " fill="#e22126" transform="translate(405,444)"/>
<path d="M0,0 L13,1 L18,3 L17,8 L14,9 L7,7 L-2,8 L-5,11 L-5,16 L0,19 L14,23 L19,27 L21,32 L20,39 L15,45 L7,48 L-1,48 L-12,44 L-14,40 L-12,38 L-6,39 L1,41 L9,40 L12,38 L13,33 L10,29 L-5,25 L-10,22 L-13,16 L-12,8 L-8,3 Z " fill="#e22126" transform="translate(227,454)"/>
<path d="M0,0 L6,0 L14,15 L16,20 L18,17 L27,0 L33,0 L34,3 L21,27 L20,46 L14,46 L13,45 L12,28 L1,6 L-1,1 Z " fill="#e22126" transform="translate(76,455)"/>
<path d="M0,0 L5,0 L7,3 L7,31 L9,37 L16,40 L23,39 L27,34 L28,1 L29,0 L34,0 L35,1 L35,36 L31,42 L24,46 L22,47 L13,47 L4,43 L0,37 L-1,34 L-1,1 Z " fill="#e22126" transform="translate(121,455)"/>
<path d="M0,0 L5,0 L6,1 L7,22 L20,7 L26,0 L33,0 L32,4 L22,15 L20,19 L31,37 L35,43 L35,46 L28,46 L22,37 L15,26 L11,28 L7,33 L6,45 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(170,455)"/>
<path d="M0,0 L31,0 L32,5 L31,6 L7,7 L6,20 L27,20 L28,21 L28,26 L7,27 L6,40 L31,40 L32,45 L31,46 L0,46 L-1,45 L-1,2 Z " fill="#e22126" transform="translate(260,455)"/>
<path d="M0,0 L5,0 L5,23 L10,18 L19,8 L26,0 L32,0 L32,3 L23,14 L20,17 L21,21 L33,41 L35,46 L27,46 L15,26 L12,26 L6,33 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(305,455)"/>
<path d="M0,0 L11,0 L18,3 L23,9 L24,13 L24,34 L20,42 L10,47 L1,47 L-7,43 L-11,38 L-12,35 L-12,11 L-8,4 Z M1,6 L-4,10 L-5,12 L-5,34 L-1,39 L7,41 L14,38 L17,34 L17,13 L14,8 L10,6 Z " fill="#e22126" transform="translate(361,455)"/>
<path d="M0,0 L12,0 L19,3 L17,8 L11,7 L2,7 L-3,10 L-5,14 L-5,32 L-2,38 L6,40 L13,38 L14,28 L8,28 L7,27 L7,22 L21,22 L21,41 L16,45 L10,47 L1,47 L-7,43 L-12,37 L-12,10 L-8,4 Z " fill="#e22126" transform="translate(410,455)"/>
<path d="M0,0 L22,0 L29,3 L33,10 L33,18 L29,24 L26,26 L27,31 L33,44 L33,46 L26,46 L18,30 L18,28 L6,28 L6,44 L5,46 L-1,46 L-1,1 Z M6,6 L6,22 L19,22 L25,19 L26,17 L26,11 L23,7 L20,6 Z " fill="#e22126" transform="translate(445,455)"/>
<path d="M0,0 L30,0 L31,1 L31,6 L6,6 L6,20 L27,20 L28,23 L27,26 L6,26 L6,40 L30,40 L31,41 L31,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(491,455)"/>
<path d="M0,0 L33,0 L34,5 L33,6 L20,6 L20,46 L13,46 L12,7 L-1,6 L-1,1 Z " fill="#e22126" transform="translate(532,455)"/>
<path d="M0,0 L5,0 L6,6 L6,44 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(579,455)"/>
<path d="M0,0 L6,1 L20,28 L21,29 L35,2 L37,0 L42,1 L43,2 L43,45 L42,46 L36,46 L35,18 L24,39 L18,39 L8,21 L6,17 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(601,455)"/>
<path d="M0,0 L5,0 L6,1 L7,22 L14,15 L23,4 L27,0 L33,0 L33,3 L21,17 L22,22 L36,44 L36,46 L28,46 L15,25 L10,30 L7,33 L6,46 L0,46 L-1,33 Z " fill="#e22126" transform="translate(680,455)"/>
<path d="M0,0 L5,0 L6,1 L7,35 L8,38 L14,40 L22,39 L26,35 L27,33 L28,1 L29,0 L34,0 L35,2 L35,33 L32,41 L26,45 L21,47 L12,47 L4,43 L0,39 L-1,36 L-1,1 Z " fill="#e22126" transform="translate(726,455)"/>
<path d="M0,0 L22,0 L29,3 L33,9 L33,18 L30,23 L26,28 L33,43 L33,46 L26,46 L18,29 L6,28 L6,46 L0,46 L-1,45 L-1,1 Z M7,6 L6,7 L6,21 L7,22 L20,22 L25,19 L26,18 L26,10 L21,6 Z " fill="#e22126" transform="translate(774,455)"/>
<path d="M0,0 L5,0 L6,1 L7,35 L11,39 L18,40 L25,37 L27,31 L28,0 L34,0 L34,37 L28,44 L21,47 L12,47 L4,43 L-1,37 L-2,33 L-2,2 Z " fill="#e22126" transform="translate(820,455)"/>
<path d="M0,0 L5,0 L6,4 L6,40 L30,40 L31,41 L31,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(869,455)"/>
<path d="M0,0 L5,0 L6,1 L7,35 L11,39 L19,40 L25,37 L27,32 L28,1 L29,0 L34,0 L35,1 L35,34 L31,42 L24,46 L21,47 L12,47 L4,43 L0,38 L-1,36 L-1,1 Z " fill="#e22126" transform="translate(911,455)"/>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -229,8 +229,12 @@ export async function POST(request: NextRequest) {
reason: "refund",
refId: kullaniciMesajId,
});
console.error("[soru] AI istemcisi kurulamadı (anahtar eksik olabilir)");
return Response.json(
{ error: "AI altyapısı yapılandırılmadı." },
{
error:
"Danışman şu anda yanıt veremiyor; kredin iade edildi. Birazdan tekrar dener misin?",
},
{ status: 503 },
);
}

45
src/app/error.tsx Normal file
View File

@@ -0,0 +1,45 @@
"use client";
// Kök hata sınırı: beklenmedik sunucu/istemci hatalarında Next'in çıplak
// hata ekranı yerine markalı, kurtarılabilir bir ekran gösterir.
import { useEffect } from "react";
import Link from "next/link";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[hata-siniri]", error);
}, [error]);
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-md flex-col items-center justify-center px-4 py-16 text-center">
<TriangleAlert className="size-10 text-amber-500" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Bir şeyler ters gitti
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Beklenmedik bir hata oluştu. Tekrar dener misin? Sorun devam ederse
birkaç dakika sonra yeniden gel verilerin güvende.
</p>
<div className="mt-6 flex gap-3">
<Button
onClick={reset}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Tekrar dene
</Button>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/">Ana sayfaya dön</Link>
</Button>
</div>
</main>
);
}

View File

@@ -34,6 +34,21 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
const [email, setEmail] = useState("");
const [busy, setBusy] = useState<"google" | "email" | null>(null);
const [sent, setSent] = useState(false);
// Yeniden gönderim arası bekleme (sn) — istemeden spam'lenmesin
const [bekleme, setBekleme] = useState(0);
function geriSayimBaslat() {
setBekleme(30);
const sayac = setInterval(() => {
setBekleme((s) => {
if (s <= 1) {
clearInterval(sayac);
return 0;
}
return s - 1;
});
}, 1000);
}
async function googleIleGiris() {
setBusy("google");
@@ -47,8 +62,8 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
}
}
async function linkGonder(e: React.FormEvent) {
e.preventDefault();
async function linkGonder(e?: React.FormEvent) {
e?.preventDefault();
if (!/^\S+@\S+\.\S+$/.test(email)) {
toast.error("Geçerli bir e-posta adresi gir.");
return;
@@ -64,11 +79,13 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
return;
}
setSent(true);
geriSayimBaslat();
}
if (sent) {
return (
<div className="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
<div className="mt-6 space-y-3">
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
<p className="font-semibold">Bağlantı gönderildi 📬</p>
<p className="mt-1">
<span className="font-medium">{email}</span> adresine bir giriş
@@ -76,6 +93,28 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
bağlantı 5 dakika geçerli.
</p>
</div>
<div className="flex items-center justify-center gap-4 text-sm">
<button
type="button"
onClick={() => linkGonder()}
disabled={busy !== null || bekleme > 0}
className="cursor-pointer font-medium text-primary underline-offset-2 hover:underline disabled:cursor-default disabled:text-slate-400 disabled:no-underline"
>
{busy === "email"
? "Gönderiliyor…"
: bekleme > 0
? `Yeniden gönder (${bekleme})`
: "Yeniden gönder"}
</button>
<button
type="button"
onClick={() => setSent(false)}
className="cursor-pointer text-slate-500 underline-offset-2 hover:underline"
>
E-postayı düzelt
</button>
</div>
</div>
);
}

39
src/app/giris/loading.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { Skeleton } from "@/components/ui/skeleton";
// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık,
// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir —
// içerik gelince zıplama olmaz.
export default function GirisLoading() {
return (
<div className="flex min-h-screen flex-col bg-slate-50">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{/* Başlık + alt metin */}
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
{/* Google ile devam et */}
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
{/* "veya" ayracı */}
<div className="mt-4 flex items-center gap-3">
<Skeleton className="h-px flex-1" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-px flex-1" />
</div>
{/* E-posta alanı + gönder butonu */}
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
</div>
{/* Kart altındaki yasal metin */}
<div className="mt-6 flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</main>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { GirisForm } from "./giris-form";
@@ -46,8 +47,15 @@ export default async function GirisPage({
<GirisForm callbackURL={callbackURL} />
</div>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla
işlenmesini kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
<Link href="/gizlilik" className="underline hover:text-slate-700">
gizlilik metnindeki
</Link>{" "}
gibi işlenmesini ve{" "}
<Link href="/kosullar" className="underline hover:text-slate-700">
kullanım koşullarını
</Link>{" "}
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
</p>
</main>
</div>

66
src/app/gizlilik/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Gizlilik ve KVKK — KolayTercih",
};
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function GizlilikPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">
Gizlilik ve Kişisel Verilerin Korunması
</h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p>
<div className="mt-8 space-y-6 text-sm leading-7 text-slate-700">
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hangi verileri işliyoruz?
</h2>
<p className="mt-2">
KolayTercih; e-posta adresini (hesap oluşturma ve giriş için), YKS
başarı sıralamanı ve sihirbazda yaptığın seçimleri (tercih listeni
üretmek için), danışman sohbetine yazdığın mesajları (sorularını
yanıtlamak için) ve ödeme yaptığında sipariş kayıtlarını işler.
Kart bilgilerin bize hiç ulaşmaz; ödeme iyzico altyapısında
gerçekleşir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ne için kullanıyoruz?
</h2>
<p className="mt-2">
Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi
bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş bağlantısı,
ödeme bilgilendirmesi) göndermek için kullanılır. Verilerini
üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Saklama ve silme
</h2>
<p className="mt-2">
Hesabını sildirmek veya verilerinin bir kopyasını istemek için{" "}
<Link href="/iletisim" className="text-primary underline">
iletişim sayfasındaki
</Link>{" "}
adresten bize yazabilirsin; talepler 30 gün içinde sonuçlandırılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Çerezler
</h2>
<p className="mt-2">
Yalnızca oturumunu ık tutmak için zorunlu çerezler kullanılır;
reklam veya izleme çerezi kullanılmaz.
</p>
</section>
</div>
</main>
);
}

View File

@@ -144,6 +144,23 @@
animation: kt-unblur 0.9s ease-out both;
}
/* Hareket azaltılmışsa blur çözülmesi yerine kısa, yumuşak bir belirme:
kavramayı sağlayan geri bildirim kalır, gösterişli efekt düşer. */
@media (prefers-reduced-motion: reduce) {
.kt-unblur {
animation: kt-unblur-sade 0.25s ease-out both;
}
}
@keyframes kt-unblur-sade {
from {
opacity: 0.6;
}
to {
opacity: 1;
}
}
@layer base {
* {
@apply border-border outline-ring/50;

30
src/app/iletisim/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Mail } from "lucide-react";
export const metadata: Metadata = {
title: "İletişim — KolayTercih",
};
export default function IletisimPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">İletişim</h1>
<p className="mt-4 text-sm leading-7 text-slate-700">
Ödeme, iade, hesap silme ya da başka bir konuda yardıma mı ihtiyacın
var? Tercih döneminde mesajlara aynı gün dönmeye çalışıyoruz.
</p>
<a
href="mailto:destek@kolaytercih.com"
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<Mail className="size-4 text-primary" aria-hidden />
destek@kolaytercih.com
</a>
<p className="mt-6 text-xs text-slate-500">
Ödemenle ilgili yazıyorsan e-postana sipariş tarihini ve kullandığın
hesap adresini eklersen daha hızlı yardımcı olabiliriz. Kart bilgini
asla e-postayla paylaşma.
</p>
</main>
);
}

74
src/app/kosullar/page.tsx Normal file
View File

@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Kullanım ve Satış Koşulları — KolayTercih",
};
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function KosullarPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">
Kullanım ve Satış Koşulları
</h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p>
<div className="mt-8 space-y-6 text-sm leading-7 text-slate-700">
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hizmetin kapsamı
</h2>
<p className="mt-2">
KolayTercih, resmî YÖK Atlas verilerine dayanan bir karar destek
aracıdır. Öneriler bilgilendirme amaçlıdır; KolayTercih yerleşme
garantisi vermez ve ÖSYM ya da YÖK ile bağlantılı değildir. Tercih
listenin son hali ve başvuru sorumluluğu sana aittir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ücretlendirme
</h2>
<p className="mt-2">
Temel program arama ücretsizdir. Tercih Dönemi Paketi tek seferlik
bir satın almadır; abonelik değildir, otomatik yenilenmez. Paket
içeriği ve güncel fiyat{" "}
<Link href="/paket" className="text-primary underline">
paket sayfasında
</Link>{" "}
yazar.
</p>
</section>
<section id="iade" className="scroll-mt-24">
<h2 className="font-heading text-lg font-bold text-slate-900">
İade ve cayma
</h2>
<p className="mt-2">
Paket, satın alır almaz ifasına başlanan dijital bir içeriktir.
Buna rağmen kişisel listen henüz üretilmemişse (paket hiç
kullanılmamışsa) satın almadan itibaren 14 gün içinde koşulsuz
iade talep edebilirsin. Teknik bir sorun nedeniyle hizmeti
alamadıysan, kullanımdan bağımsız olarak ücretin iade edilir.
İade talepleri için{" "}
<Link href="/iletisim" className="text-primary underline">
bize yaz
</Link>
; talepler 14 gün içinde sonuçlandırılır ve iade, ödeme yaptığın
karta yapılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hesap ve adil kullanım
</h2>
<p className="mt-2">
Krediler hesabına tanımlıdır, kişiseldir ve üçüncü kişilere
devredilemez. Hizmeti kötüye kullanan (otomatik istek, içerik
kazıma vb.) hesaplar kapatılabilir.
</p>
</section>
</div>
</main>
);
}

View File

@@ -5,6 +5,7 @@ import { SiteHeader } from "@/components/site-header";
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel";
import "./globals.css";
const outfit = Outfit({
@@ -63,6 +64,8 @@ export default function RootLayout({
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesi />
<Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}
</body>
</html>
);

View File

@@ -0,0 +1,19 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ListemLoading() {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-10">
<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-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex flex-col gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
<Skeleton className="h-[480px] w-full rounded-2xl" />
</div>
</main>
);
}

View File

@@ -3,12 +3,14 @@ import Link from "next/link";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import {
AlertTriangle,
ArrowRight,
Bot,
Check,
Database,
FileText,
Lock,
MessageCircleQuestion,
PencilLine,
ShieldCheck,
Sparkles,
@@ -16,7 +18,7 @@ import {
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import { URUNLER } from "@/lib/credits";
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
@@ -24,6 +26,7 @@ import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
export const metadata: Metadata = {
title: "Listem — KolayTercih",
@@ -33,11 +36,19 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
export default async function ListemPage() {
export default async function ListemPage({
searchParams,
}: {
searchParams: Promise<{ acildi?: string }>;
}) {
await verifySession("/listem");
const user = await getCurrentUser();
if (!user) redirect("/giris?callback=/listem");
// ?acildi=1: ödeme dönüşü — kilidin kalktığı tek seferlik açılış deneyimi
const { acildi } = await searchParams;
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
const satir = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
@@ -73,12 +84,29 @@ export default async function ListemPage() {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
{kilitAcilisi ? (
<div
role="status"
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
Paketin aktif 24 tercihinin tamamı, gerekçeler, riskler ve PDF
artık ık. 🎉
</div>
) : null}
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
{user.hasPaket ? (
<Badge variant="secondary" className="mb-3">
<ShieldCheck aria-hidden />
Paketin aktif
</Badge>
) : (
<Badge variant="secondary" className="mb-3">
<Database aria-hidden />
YÖK Atlas verileri ücretsiz
</Badge>
)}
<h1 className="max-w-2xl font-heading text-2xl font-bold tracking-tight sm:text-3xl">
{user.hasPaket
? "Tercih karar merkezin"
@@ -86,8 +114,10 @@ export default async function ListemPage() {
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{params.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[params.tur]} · 24 programın 20242025 taban
sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin.
{PUAN_TURLERI[params.tur]} ·{" "}
{user.hasPaket
? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin."
: "24 programın 20242025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
</p>
</div>
{user.hasPaket ? (
@@ -98,9 +128,7 @@ export default async function ListemPage() {
size="sm"
className="cursor-pointer"
>
<Link
href={`/sonuc?sira=${params.sira}&tur=${params.tur}&sihirbaz=1`}
>
<Link href="#revizyon">
<PencilLine className="size-4" aria-hidden />
Revize et
</Link>
@@ -120,8 +148,49 @@ export default async function ListemPage() {
) : null}
</div>
{/* AI'ın genel değerlendirmesi ve uyarıları: paketliye tam,
paketsize sunucuda kırpılmış önizleme + kilit notu */}
{rapor.genelDegerlendirme ? (
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 font-heading text-base font-bold">
<Bot className="size-4 text-primary" aria-hidden />
Danışman özeti
</h2>
<p className="mt-2 text-sm leading-6 text-slate-700">
{rapor.genelDegerlendirme}
</p>
{user.hasPaket && rapor.uyarilar?.length ? (
<ul className="mt-3 flex flex-col gap-2">
{rapor.uyarilar.map((uyari) => (
<li
key={uyari}
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<AlertTriangle
className="mt-0.5 size-4 shrink-0"
aria-hidden
/>
{uyari}
</li>
))}
</ul>
) : null}
{!user.hasPaket ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Değerlendirmenin tamamı ve sıralamana özel uyarılar pakette.
</p>
) : null}
</section>
) : null}
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<section className="min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5">
<div className="flex min-w-0 flex-col gap-6">
<section
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
kilitAcilisi ? " kt-unblur" : ""
}`}
>
<RaporListesi
rapor={rapor}
adaySira={params.sira}
@@ -134,11 +203,12 @@ export default async function ListemPage() {
</span>
<div>
<p className="font-heading font-bold text-slate-900">
Ham veri burada bitmiyor aşağıdaki tüm satırlar ık
İlk {ACIK_SATIR} tercihte AI analizini gördün
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Paket; kalan {24 - ACIK_SATIR} tercih in kişisel risk,
gerekçe ve AI yorumunu açar.
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
AI yorumu paketle ılır. Program adları ve taban verileri
her zaman ücretsiz.
</p>
</div>
</div>
@@ -147,7 +217,7 @@ export default async function ListemPage() {
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"
>
<Link href="/paket">
Analizleri
Analizleri {PAKET_FIYATI} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
@@ -156,10 +226,18 @@ export default async function ListemPage() {
/>
</section>
<div className="lg:sticky lg:top-28">
{user.hasPaket ? (
<RevizyonKutusu
kalanHak={Math.max(0, MAX_REVIZYON - (satir.revisionCount ?? 0))}
/>
) : null}
</div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
{user.hasPaket ? (
<SohbetClient baslangicKredi={user.creditBalance} />
) : (
<div className="flex flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 p-6">
<div className="flex items-center gap-2 text-primary">
@@ -230,9 +308,27 @@ export default async function ListemPage() {
</div>
</div>
</section>
{/* Deneme kredileri sohbette geçerli: AI'ı satın almadan önce
tattırır (kalan kredi biterse composer paket CTA'sına döner). */}
<SohbetClient
baslangicKredi={user.creditBalance}
className="h-[480px]"
/>
</div>
)}
</div>
</div>
{/* Mobilde sohbet sağ rayla birlikte en alta düşer; yüzen kısayol
kullanıcıyı 24 satırı kaydırmadan danışmana ulaştırır. */}
<a
href="#sohbet"
className="fixed bottom-5 right-4 z-40 inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-semibold text-white shadow-lg transition-transform duration-200 active:scale-[0.97] lg:hidden print:hidden"
>
<MessageCircleQuestion className="size-4" aria-hidden />
Danışmana sor
</a>
</main>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
// Paketlinin geri bildirime dayalı liste revizyonu (/listem üzerinde).
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın.
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { listeRevize } from "@/app/sonuc/actions";
export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
const router = useRouter();
if (kalanHak <= 0) {
return (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-white p-5"
>
<p className="text-sm text-slate-500">
Revizyon hakların doldu. Danışman sohbetinden listen hakkında soru
sormaya devam edebilirsin.
</p>
</section>
);
}
async function revizeEt() {
setPending(true);
try {
const sonuc = await listeRevize({
feedback: feedback.trim(),
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
toast.success("Listen yeniden kuruldu.");
setFeedback("");
router.refresh();
} else {
toast.error(sonuc.error);
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
} finally {
setPending(false);
}
}
return (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-white p-5"
>
<h2 className="font-heading text-base font-bold">
Listede değişiklik mi istiyorsun?
</h2>
<p className="mb-3 mt-1 text-sm text-slate-600">
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
(3 kredi).
</p>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
maxLength={1000}
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
disabled={pending}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<div className="mt-3 flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={pending || feedback.trim().length < 5}
onClick={revizeEt}
className="cursor-pointer"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCcw className="size-4" aria-hidden />
)}
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button>
<span className="text-xs text-slate-500">
Kalan hak: <span className="font-semibold">{kalanHak}</span>
</span>
</div>
</section>
);
}

13
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Loader2 } from "lucide-react";
export default function Loading() {
return (
<main
aria-live="polite"
className="flex min-h-[60vh] flex-col items-center justify-center gap-3"
>
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
<p className="text-sm text-slate-500">Yükleniyor</p>
</main>
);
}

24
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { Compass } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-md flex-col items-center justify-center px-4 py-16 text-center">
<Compass className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Böyle bir sayfa yok
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Sıralamanı
girip listeni kurmaya ana sayfadan devam edebilirsin.
</p>
<Button
asChild
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/">Ana sayfaya dön</Link>
</Button>
</main>
);
}

View File

@@ -39,20 +39,14 @@ export default async function OdemeSonucPage({
const basarili = durum === "paid";
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// sonuç sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
columns: { params: true },
columns: { id: true },
})
: null;
const raporParams = rapor?.params as
| { sira?: number; tur?: string }
| undefined;
const listeHref =
raporParams?.sira && raporParams?.tur
? `/sonuc?sira=${raporParams.sira}&tur=${raporParams.tur}&acildi=1`
: null;
const listeHref = rapor ? "/listem?acildi=1" : null;
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
@@ -101,9 +95,18 @@ export default async function OdemeSonucPage({
Ödeme tamamlanamadı
</h1>
<p className="mt-2 text-sm text-slate-600">
{durum === "pending"
? "Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
: "Çekim yapılmadı ya da işlem iptal edildi. Kartından para çekildiğini düşünüyorsan bizimle iletişime geç."}
{durum === "pending" ? (
"Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
) : (
<>
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
çekildiğini düşünüyorsan{" "}
<Link href="/iletisim" className="underline">
bizimle iletişime geç
</Link>
.
</>
)}
</p>
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
<Link href="/paket">Tekrar dene</Link>

View File

@@ -9,7 +9,10 @@ import {
Wallet,
X,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { HeroForm } from "@/components/hero-form";
import { URUNLER } from "@/lib/credits";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -33,6 +36,13 @@ import {
TableRow,
} from "@/components/ui/table";
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
const problems = [
{
icon: ListChecks,
@@ -96,8 +106,7 @@ const faqs = [
},
{
question: "Fiyatlandırma nasıl çalışıyor?",
answer:
"Abonelik yok. Tercih dönemi boyunca geçerli tek seferlik paket alırsın: yapay zekâ destekli kişisel 24'lük liste, risk analizi, liste revizyonları ve listen hakkında soru hakkı dahil. Temel program arama ise herkes için ücretsiz.",
answer: `Abonelik yok. Tercih dönemi boyunca geçerli tek seferlik ${PAKET_FIYATI} TL'lik paket alırsın: yapay zekâ destekli kişisel 24'lük liste, risk analizi, liste revizyonları ve ${URUNLER.paket.credits} soru hakkı dahil. Soru hakların biterse ${TOPUP_FIYATI} TL'ye +${URUNLER.topup.credits} soru ekleyebilirsin. Temel program arama ise herkes için ücretsiz.`,
},
];
@@ -155,7 +164,7 @@ export default function Home() {
variant="secondary"
className="mb-6 bg-primary/10 text-primary"
>
2026 YKS tercih dönemi ıkk
2026 YKS tercih dönemi ık
</Badge>
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
Dört yıllık geleceğini{" "}
@@ -173,27 +182,25 @@ export default function Home() {
<HeroForm />
</div>
<p className="mt-4 text-sm text-slate-500">
Temel arama her zaman ücretsiz · Kredi kartı gerekmez
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz
paketi tek seferlik {PAKET_FIYATI} TL
</p>
{/* Sosyal kanıt: henüz kullanıcı yok — dürüstçe veri istatistikleri */}
<dl className="mt-16 grid w-full max-w-3xl grid-cols-1 gap-4 sm:grid-cols-3">
{[
{ value: "23.000+", label: "Taranan lisans programı" },
{ value: "4 yıl", label: "Taban sıralaması trend verisi" },
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" },
].map((stat) => (
<div
key={stat.label}
className="rounded-2xl border border-slate-200 bg-white p-6"
>
<dt className="text-sm text-slate-500">{stat.label}</dt>
<dd className="mt-1 font-heading text-3xl font-bold text-primary">
{stat.value}
</dd>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
<Image
src="/yok-logo.svg"
alt="Yükseköğretim Kurulu"
width={68}
height={33}
className="h-7 w-auto shrink-0"
/>
<span className="h-5 w-px shrink-0 bg-slate-200" aria-hidden />
<span>
<strong className="font-semibold text-slate-900">
YÖK Atlas&apos;ın gerçek verileriyle
</strong>{" "}
güvenilir sonuçlar
</span>
</div>
))}
</dl>
</section>
{/* Problem */}
@@ -247,6 +254,43 @@ export default function Home() {
</div>
))}
</div>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
<div className="mx-auto mt-12 w-full max-w-3xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<dl className="grid grid-cols-1 divide-y divide-slate-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{[
{ value: "23.000+", label: "Taranan lisans programı" },
{ value: "4 yıl", label: "Taban sıralaması trend verisi" },
{ 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">
{stat.value}
</dd>
</div>
))}
</dl>
<div className="flex gap-4 border-t border-slate-200 bg-primary/5 p-6 text-left sm:items-center sm:p-7">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
Dürüstlük notu
</p>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl">
Yerleşme garantisi vermiyoruz çünkü kimse veremez.
</p>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
Taban sıralamaları her yıl değişir. Biz sonuç vaadi satmak
yerine riski satır satır gösterir, daha güvenli bir liste
kurmana yardımcı oluruz.
</p>
</div>
</div>
</div>
</div>
</section>
@@ -341,7 +385,7 @@ export default function Home() {
</section>
</main>
<footer className="border-t border-slate-200 bg-white py-10">
<footer className="border-t border-slate-200 bg-white pb-32 pt-10">
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
<p>
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
@@ -351,6 +395,20 @@ export default function Home() {
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
</p>
<nav
aria-label="Yasal sayfalar"
className="mt-4 flex items-center justify-center gap-4"
>
<Link href="/gizlilik" className="hover:text-slate-700 hover:underline">
Gizlilik & KVKK
</Link>
<Link href="/kosullar" className="hover:text-slate-700 hover:underline">
Kullanım & iade koşulları
</Link>
<Link href="/iletisim" className="hover:text-slate-700 hover:underline">
İletişim
</Link>
</nav>
</div>
</footer>
</div>

View File

@@ -50,9 +50,11 @@ export async function baslatOdeme(
});
} catch (err) {
if (err instanceof Error && err.message === "IYZICO_KEYS_MISSING") {
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
console.error("[odeme] iyzico anahtarları eksik — ödeme başlatılamadı");
return {
error:
"Ödeme altyapısı henüz yapılandırılmadı (iyzico anahtarları eksik).",
"Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?",
};
}
throw err;

View File

@@ -40,12 +40,29 @@ const OZELLIKLER = [
},
] as const;
export default async function PaketPage() {
export default async function PaketPage({
searchParams,
}: {
searchParams: Promise<{ hata?: string }>;
}) {
const u = await getCurrentUser();
const { hata } = await searchParams;
// Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner
const odemeHatasi = hata === "token" || hata === "siparis";
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
{odemeHatasi ? (
<div
role="alert"
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
deneyebilirsin çekim yapılmadıysa hiçbir ücret ödemedin.
</div>
) : null}
<div className="text-center">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Tek seferlik ödeme · Abonelik yok
@@ -111,7 +128,13 @@ export default async function PaketPage() {
)}
</div>
<p className="mt-4 text-center text-xs text-slate-500">
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
<Link
href="/kosullar#iade"
className="underline hover:text-slate-700"
>
İade koşulları
</Link>
</p>
</section>
@@ -149,7 +172,11 @@ export default async function PaketPage() {
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine
dayanır ancak yerleşme garantisi verilmez. Tercih listesinin nihai
sorumluluğu adaya aittir.
sorumluluğu adaya aittir. Sorun mu var?{" "}
<Link href="/iletisim" className="underline hover:text-slate-700">
Bize yaz
</Link>
.
</p>
</main>
</div>

View File

@@ -4,7 +4,12 @@ import { eq, sql } from "drizzle-orm";
import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import { spendCredits, grantCredits, RAPOR_KREDI } from "@/lib/credits";
import {
spendCredits,
grantCredits,
RAPOR_KREDI,
MAX_REVIZYON,
} from "@/lib/credits";
import {
raporUret,
listeOzetiCikar,
@@ -15,7 +20,6 @@ import type { RaporParams } from "@/lib/rapor-havuzu";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
import { raporMaskele } from "@/lib/rapor-maske";
const MAX_REVIZYON = 2;
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
export type ListeSonuc =
@@ -38,7 +42,9 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
function aiHataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message;
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
return "AI altyapısı henüz yapılandırılmadı (ANTHROPIC_API_KEY eksik).";
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
console.error("[liste] ANTHROPIC_API_KEY eksik — üretim başlatılamadı");
return "Liste üretimi şu anda başlatılamıyor. Birazdan tekrar dener misin? Harcanan kredin otomatik iade edilir.";
}
console.error("[liste] üretim hatası:", err);
return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?";

19
src/app/sonuc/loading.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Skeleton } from "@/components/ui/skeleton";
// /sonuc senkron SQLite sorgularıyla açılır; yavaş bağlantıda beyaz ekran
// yerine sayfanın iskeletini göster.
export default function SonucLoading() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<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" />
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
<div className="mt-8 flex flex-col gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
</main>
);
}

View File

@@ -40,6 +40,7 @@ export default async function SonucPage({
tur?: string;
sihirbaz?: string;
acildi?: string;
hazir?: string;
}>;
}) {
const params = await searchParams;
@@ -145,6 +146,7 @@ export default async function SonucPage({
mevcutRapor={mevcutRapor}
otomatikAc={params.sihirbaz === "1"}
acilis={params.acildi === "1"}
secimlerHazir={params.hazir === "1"}
/>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">

View File

@@ -40,8 +40,12 @@ type BekleyenSecim = {
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
* - Adımları toplayan modal
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
* Akış: adımlar → (girişsizse /giris) → kredi ile üretim → paketsizse
* progressive-blur önizleme, paketliyse tam liste.
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
* gibi doğrudan girişe gider.
*/
export function SihirbazBolumu({
sira,
@@ -53,6 +57,7 @@ export function SihirbazBolumu({
mevcutRapor,
otomatikAc,
acilis,
secimlerHazir,
}: {
sira: number;
tur: string;
@@ -65,6 +70,10 @@ export function SihirbazBolumu({
otomatikAc?: boolean;
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
acilis?: boolean;
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
secimlerHazir?: boolean;
}) {
const router = useRouter();
const monteEdildi = useMonteEdildi();
@@ -280,8 +289,25 @@ export function SihirbazBolumu({
}
const panelde = Boolean(rapor) || durum !== "bos";
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
// mount sonrası değişir ve AnimatePresence geçişi donardı.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
const girisUrl = `/giris?callback=${encodeURIComponent(
`/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`,
)}`;
const panelAnahtari =
durum !== "bos" ? durum : rapor ? "rapor-hazir" : "sihirbaz-cta";
durum !== "bos"
? durum
: rapor
? "rapor-hazir"
: girisBekliyor
? "giris-cta"
: "sihirbaz-cta";
return (
<>
@@ -370,6 +396,31 @@ export function SihirbazBolumu({
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır AI listen bir adım uzakta
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik AI listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={girisUrl}>
<Sparkles className="size-4" aria-hidden />5 deneme kredinle
AI listeni kur giriş yap
</Link>
</Button>
<p className="mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
@@ -400,12 +451,25 @@ export function SihirbazBolumu({
<div 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">
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
3 adımda 24 tercihlik listen
{girisBekliyor
? "Seçimlerin kayıtlı — AI listen hazır bekliyor"
: "3 adımda 24 tercihlik listen"}
</p>
<p className="text-xs text-slate-500">
Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun.
{girisBekliyor
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
: "Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun."}
</p>
</div>
{girisBekliyor ? (
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={girisUrl}>Giriş yap</Link>
</Button>
) : (
<Button
size="sm"
onClick={() => acikDegisti(true)}
@@ -413,6 +477,7 @@ export function SihirbazBolumu({
>
Başla
</Button>
)}
<button
type="button"
aria-label="Kapat"
@@ -441,7 +506,7 @@ export function SihirbazBolumu({
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
}
sonButonEtiketi={
girisliMi ? "Listemi oluştur (3 kredi)" : "Listemi gör"
girisliMi ? "Listemi oluştur (3 kredi)" : "Giriş yap ve listeni gör"
}
onTamamla={ilerle}
/>

View File

@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
type Variants,
} from "motion/react";
@@ -36,8 +37,12 @@ export function TextLoop({
}: TextLoopProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const items = Children.toArray(children);
// Sürekli dönen dekoratif metin: hareket azaltılsın diyen kullanıcıda
// döngü hiç başlamaz, ilk metin sabit kalır.
const azMotion = useReducedMotion();
useEffect(() => {
if (azMotion) return;
const intervalMs = interval * 1000;
const timer = setInterval(() => {
setCurrentIndex((current) => {
@@ -47,7 +52,7 @@ export function TextLoop({
});
}, intervalMs);
return () => clearInterval(timer);
}, [items.length, interval, onIndexChange]);
}, [azMotion, items.length, interval, onIndexChange]);
return (
<div className={cn("relative inline-block whitespace-nowrap", className)}>

View File

@@ -0,0 +1,45 @@
"use client";
import { Children, type ReactNode } from "react";
import {
AnimatePresence,
motion,
type Transition,
type Variants,
} from "motion/react";
type TransitionPanelProps = {
activeIndex: number;
children: ReactNode;
variants: Variants;
transition?: Transition;
custom?: number;
};
export function TransitionPanel({
activeIndex,
children,
variants,
transition,
custom,
}: TransitionPanelProps) {
const paneller = Children.toArray(children);
return (
<div className="relative overflow-hidden">
<AnimatePresence initial={false} custom={custom}>
<motion.div
key={activeIndex}
custom={custom}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={transition}
>
{paneller[activeIndex]}
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,344 @@
"use client";
// Dev süperadmin paneli: yalnızca geliştirme ortamında render edilir
// (layout.tsx NODE_ENV kontrolüyle monte eder; aksiyonlar da sunucuda
// ayrıca korunur). Amaç: satın alma / giriş / kredi durumlarını tek
// tıkla taklit edip uygulamanın tüm sayfalarını gerçek akışta gezmek.
//
// Hareket dili (Apple "Designing Fluid Interfaces"): panel DEV çipinden
// büyür ve yine ona küçülür (paylaşılan layoutId ile mekânsal bağ), yay
// kritik sönümlüdür (bounce 0) — açılış ortasında kapatılırsa mevcut
// değerden geri döner. Yüzey opak kutu değil, yarı saydam malzemedir.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Wrench, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
devAnindaGiris,
devAyarla,
devDurum,
devRaporSil,
devSahteOdeme,
type DevDurum,
} from "@/lib/dev/actions";
const SAYFALAR: { ad: string; href: string }[] = [
{ ad: "Ana sayfa", href: "/" },
{ ad: "Sonuç (85k · SAY)", href: "/sonuc?sira=85000&tur=say&sihirbaz=1" },
{ ad: "Sonuç (girişsiz hazır)", href: "/sonuc?sira=85000&tur=say&hazir=1" },
{ ad: "Listem", href: "/listem" },
{ ad: "Listem (kilit açılışı)", href: "/listem?acildi=1" },
{ ad: "Paket", href: "/paket" },
{ ad: "Paket (ödeme hatası)", href: "/paket?hata=token" },
{ ad: "PDF / yazdır", href: "/rapor/yazdir" },
{ ad: "Giriş", href: "/giris" },
{ ad: "Gizlilik", href: "/gizlilik" },
{ ad: "Koşullar", href: "/kosullar" },
{ ad: "İletişim", href: "/iletisim" },
{ ad: "404", href: "/boyle-bir-sayfa-yok" },
];
// Apple değerleri: damping 1.0 (bounce 0), response ~0.35 sn.
// Yay süreli değildir; hedef değişirse mevcut değer + hızdan devam eder.
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
// Basış anında tepki (pointer-down feedback) — bırakınca değil.
const BASIS =
"active:scale-[0.97] transition-[transform,background-color,border-color,opacity] duration-150 ease-out";
// Yarı saydam malzeme; şeffaflık azaltılsın diyen kullanıcıya opak yüzey.
const MALZEME =
"border border-white/60 bg-white/80 shadow-2xl shadow-slate-900/20 backdrop-blur-2xl backdrop-saturate-150 " +
"[@media(prefers-reduced-transparency:reduce)]:bg-white [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none";
export function DevPanel() {
const [acik, setAcik] = useState(false);
const [durum, setDurum] = useState<DevDurum | null>(null);
const [email, setEmail] = useState("test@kolaytercih.dev");
const [mesgul, setMesgul] = useState(false);
const router = useRouter();
const azMotion = useReducedMotion();
const yenile = useCallback(async () => {
try {
setDurum(await devDurum());
} catch {
setDurum(null);
}
}, []);
function paneliAc() {
setAcik(true);
void yenile();
}
// Yol bulma: panelden çıkış her an mümkün — Escape da kapatır.
useEffect(() => {
if (!acik) return;
const f = (e: KeyboardEvent) => {
if (e.key === "Escape") setAcik(false);
};
window.addEventListener("keydown", f);
return () => window.removeEventListener("keydown", f);
}, [acik]);
// Her aksiyonu tek kalıptan geçir: meşgul kilidi + durum tazeleme +
// server component'lerin (header'daki kredi rozeti dahil) yenilenmesi
async function calistir(fn: () => Promise<unknown>) {
setMesgul(true);
try {
await fn();
await yenile();
router.refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Dev aksiyonu başarısız.");
} finally {
setMesgul(false);
}
}
async function anindaGiris() {
setMesgul(true);
try {
const sonuc = await devAnindaGiris(email);
if (!sonuc.ok) {
toast.error(sonuc.error);
return;
}
// Verify URL'i oturumu kurup callback'e yönlendirir
window.location.assign(sonuc.url);
} finally {
setMesgul(false);
}
}
async function odemeEkrani() {
setMesgul(true);
try {
const sonuc = await devSahteOdeme();
if (!sonuc.ok) {
toast.error(sonuc.error);
return;
}
router.push(`/odeme/sonuc?siparis=${sonuc.siparis}`);
} finally {
setMesgul(false);
}
}
const g = durum?.girisli ? durum : null;
// Azaltılmış hareket: morph yerine kısa çapraz solma (yaysız, kaymasız).
const paylasilan = azMotion ? {} : { layoutId: "dev-panel" };
const gecis = azMotion ? { duration: 0.15 } : YAY;
return (
<AnimatePresence initial={false}>
{!acik ? (
<motion.button
key="cip"
{...paylasilan}
type="button"
onClick={paneliAc}
title="Dev süperadmin paneli"
style={{ borderRadius: 18 }}
transition={gecis}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
className={`fixed bottom-20 left-4 z-50 inline-flex h-9 cursor-pointer items-center gap-1.5 px-3 text-xs font-bold tracking-wide text-white ${MALZEME} !bg-slate-900/85 print:hidden ${BASIS} hover:!bg-slate-800/85`}
>
<Wrench className="size-3.5 text-violet-300" aria-hidden />
DEV
</motion.button>
) : (
<motion.div
key="panel"
{...paylasilan}
role="dialog"
aria-label="Dev süperadmin paneli"
style={{ borderRadius: 24, transformOrigin: "bottom left" }}
transition={gecis}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
className={`fixed bottom-20 left-4 z-50 flex max-h-[72vh] w-80 flex-col overflow-hidden ${MALZEME} print:hidden`}
>
{/* İçerik morph sırasında bozulmasın: yüzey oturduktan hemen
sonra belirir (malzeme önce, içerik sonra). */}
<motion.div
initial={azMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: azMotion ? 0 : 0.12 }}
className="flex min-h-0 flex-col"
>
{/* Başlık: opak şerit değil, aynı malzemenin üstünde ince
bir kenar çizgisiyle ayrışan katman. */}
<div className="flex items-center justify-between border-b border-slate-900/5 px-4 py-3">
<p className="flex items-center gap-2 font-heading text-sm font-bold tracking-[-0.01em] text-slate-900">
<span className="flex size-6 items-center justify-center rounded-lg bg-violet-600 text-white shadow-sm">
<Wrench className="size-3.5" aria-hidden />
</span>
Dev süperadmin
{mesgul ? (
<Loader2
className="size-3.5 animate-spin text-slate-400"
aria-hidden
/>
) : null}
</p>
<button
type="button"
onClick={() => setAcik(false)}
className={`cursor-pointer rounded-full p-1.5 text-slate-500 hover:bg-slate-900/5 hover:text-slate-700 ${BASIS}`}
aria-label="Kapat"
>
<X className="size-4" aria-hidden />
</button>
</div>
<div className="flex flex-col gap-4 overflow-y-auto p-4 text-sm">
{/* Oturum */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Oturum
</p>
{g ? (
<div className="mt-2 rounded-xl border border-slate-900/5 bg-white/60 px-3 py-2 text-xs">
<p className="font-semibold text-slate-800">{g.email}</p>
<p className="mt-0.5 tabular-nums text-slate-500">
{g.kredi} kredi · {g.hasPaket ? "paketli" : "paketsiz"} ·{" "}
{g.raporVar
? `rapor var (${g.sira?.toLocaleString("tr-TR")} · ${g.tur})`
: "rapor yok"}
</p>
<button
type="button"
disabled={mesgul}
onClick={() =>
calistir(async () => {
await authClient.signOut();
})
}
className="mt-2 cursor-pointer font-medium text-violet-700 underline-offset-2 hover:underline"
>
Çıkış yap
</button>
</div>
) : (
<p className="mt-2 text-xs text-slate-500">
Girişsiz geziyorsun.
</p>
)}
<div className="mt-2 flex gap-1.5">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="deneme@ornek.dev"
className="h-9 min-w-0 flex-1 rounded-lg border border-slate-900/10 bg-white/70 px-2.5 text-xs outline-none transition-colors duration-150 focus-visible:border-violet-400"
/>
<button
type="button"
disabled={mesgul}
onClick={anindaGiris}
className={`h-9 shrink-0 cursor-pointer rounded-lg bg-violet-600 px-3 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-50 ${BASIS}`}
>
Anında gir
</button>
</div>
<p className="mt-1 text-[10px] text-slate-400">
Yeni e-posta = 5 kredili yeni kullanıcı (magic link atlanır).
</p>
</section>
{/* Senaryolar */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Senaryo
</p>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{(
[
{
ad: "Yeni kullanıcı",
aciklama: "5 kredi · paketsiz · raporsuz",
fn: async () => {
await devAyarla({ kredi: 5, hasPaket: false });
await devRaporSil();
},
},
{
ad: "Paketli",
aciklama: "60 kredi · paket aktif",
fn: () => devAyarla({ kredi: 60, hasPaket: true }),
},
{
ad: "Kredisi bitmiş",
aciklama: "0 kredi",
fn: () => devAyarla({ kredi: 0 }),
},
{
ad: "Raporu sil",
aciklama: "boş durumlar",
fn: () => devRaporSil(),
},
] as const
).map((s) => (
<button
key={s.ad}
type="button"
disabled={mesgul || !g}
onClick={() => calistir(s.fn)}
className={`cursor-pointer rounded-xl border border-slate-900/10 bg-white/60 px-2.5 py-2 text-left text-xs hover:border-violet-300 hover:bg-violet-50/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
>
<span className="block font-semibold text-slate-800">
{s.ad}
</span>
<span className="text-[10px] tabular-nums text-slate-500">
{s.aciklama}
</span>
</button>
))}
</div>
<button
type="button"
disabled={mesgul || !g}
onClick={odemeEkrani}
className={`mt-1.5 w-full cursor-pointer rounded-xl border border-emerald-600/15 bg-emerald-50/80 px-2.5 py-2 text-left text-xs font-semibold text-emerald-800 hover:bg-emerald-100/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
>
Ödeme başarı ekranı 🎉
<span className="block text-[10px] font-normal text-emerald-700">
sahte &quot;paid&quot; sipariş oluşturup /odeme/sonuc&apos;a
gider
</span>
</button>
</section>
{/* Sayfalar */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Sayfalar
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{SAYFALAR.map((s) => (
<a
key={s.href}
href={s.href}
className={`cursor-pointer rounded-full border border-slate-900/10 bg-white/60 px-2.5 py-1 text-[11px] text-slate-700 hover:border-violet-300 hover:bg-violet-50/80 ${BASIS}`}
>
{s.ad}
</a>
))}
</div>
</section>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -3,7 +3,6 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -13,62 +12,110 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
import { authClient } from "@/lib/auth-client";
import type { SihirbazFacetleri } from "@/lib/db";
import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz";
const TUR: string = "say";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
const PUAN_TURLERI_SECENEK = [
{ deger: "say", etiket: "Sayısal" },
{ deger: "ea", etiket: "Eşit Ağırlık" },
{ deger: "soz", etiket: "Sözel" },
{ deger: "dil", etiket: "Dil" },
{ deger: "tyt", etiket: "TYT" },
] as const;
export function HeroForm() {
const [siralama, setSiralama] = useState("");
const [tur, setTur] = useState<string>("say");
const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
const [modalAcik, setModalAcik] = useState(false);
const [hata, setHata] = useState<string | null>(null);
const router = useRouter();
const { data: oturum } = authClient.useSession();
const girisli = Boolean(oturum?.user);
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
function siralamaDegisti(ham: string) {
setHata(null);
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
setSiralama(
rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "",
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const value = Number(siralama.replace(/\./g, ""));
if (!value || value < 1 || value > 4_000_000) {
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
return;
}
setYukleniyor(true);
try {
const res = await fetch(`/api/facetler?sira=${value}&tur=${TUR}`);
const res = await fetch(`/api/facetler?sira=${value}&tur=${tur}`);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setSira(value);
setFacetler(veri.facetler);
setModalAcik(true);
} catch {
toast.error("Bir şeyler ters gitti, tekrar dener misin?");
setHata("Bir şeyler ters gitti, tekrar dener misin?");
} finally {
setYukleniyor(false);
}
}
// Seçimler tamam: sakla ve giriş kapısına yönlendir. Girişli kullanıcıyı
// /giris zaten callback'e (sonuç + otomatik üretim) sektirir; girişsiz
// kullanıcı listeyi görmeden önce funnel'lı giriş ekranıyla karşılaşır.
// Seçimler tamam: sakla ve herkes doğrudan /sonuc'a gitsin. Girişli
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
// ücretsiz tabloyu görür, giriş CTA'sı ödülün kaydedilmesi olarak
// oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil).
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (!sira) return;
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur: TUR, secimler }),
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
setModalAcik(false);
const geri = `/sonuc?sira=${sira}&tur=${TUR}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
router.push(
girisli
? `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`
: `/sonuc?sira=${sira}&tur=${tur}&hazir=1`,
);
}
return (
<>
<div className="flex w-full max-w-md flex-col items-center gap-3">
<div
role="radiogroup"
aria-label="Puan türü"
className="flex flex-wrap justify-center gap-1.5"
>
{PUAN_TURLERI_SECENEK.map((t) => (
<button
key={t.deger}
type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<form
onSubmit={handleSubmit}
className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
className="flex w-full flex-col gap-3 sm:flex-row"
>
<label htmlFor="siralama" className="sr-only">
YKS başarı sıralaması
@@ -78,7 +125,9 @@ export function HeroForm() {
inputMode="numeric"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => setSiralama(e.target.value)}
onChange={(e) => siralamaDegisti(e.target.value)}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "siralama-hata" : undefined}
className="h-12 flex-1 bg-white text-base"
/>
<Button
@@ -95,6 +144,16 @@ export function HeroForm() {
</Button>
</form>
{hata ? (
<p
id="siralama-hata"
role="alert"
className="self-start text-sm font-medium text-red-600"
>
{hata}
</p>
) : null}
<Dialog open={modalAcik} onOpenChange={setModalAcik}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 text-left sm:max-w-3xl">
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
@@ -103,14 +162,16 @@ export function HeroForm() {
</DialogDescription>
{facetler ? (
<SihirbazAdimlar
key={sira}
key={`${sira}-${tur}`}
facetler={facetler}
sonButonEtiketi="Tercih planıma geç"
sonButonEtiketi={
girisli ? "Listemi oluştur (3 kredi)" : "Ücretsiz tablonu gör"
}
onTamamla={secimleriTamamla}
/>
) : null}
</DialogContent>
</Dialog>
</>
</div>
);
}

View File

@@ -67,6 +67,9 @@ export function IlSecimHaritasi({
onClick={adet ? () => onToggle(key) : undefined}
role={adet ? "button" : undefined}
aria-pressed={adet ? seciliMi : undefined}
aria-label={
adet ? `${city.name}${adet} program` : undefined
}
>
<title>
{adet

View File

@@ -1,7 +1,7 @@
"use client";
// Manuel 24'lük listenin alt çekmecesi (bottom sheet). Layout'ta bir kez
// monte edilir; navbar'daki Listem butonu açar/kapatır.
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
//
// Motion dili (apple-design / emil-design-eng):
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi.
@@ -136,7 +136,7 @@ function CekmeceSheet() {
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Listem
Seçtiklerim
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
@@ -191,11 +191,11 @@ function CekmeceSheet() {
>
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Listen henüz boş
Henüz program seçmedin
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; 24 tercihlik listen burada birikir.
program ekle; beğendiklerin burada birikir.
</p>
</motion.div>
) : (

View File

@@ -1,7 +1,8 @@
"use client";
// Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca
// çekmeceyi açar/kapatır. Sayaç değişince rozet spring ile "pulse" yapar.
// Navbar'daki "Seçtiklerim" butonu: elle kurulan aday listesinin sayacını
// taşır, tıklanınca çekmeceyi açar/kapatır. ("Listem" adı AI raporunun
// yaşadığı /listem sayfasına aittir — isim çakışmasın.)
import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
@@ -17,17 +18,21 @@ export function ListemButonu() {
const acik = useCekmeceAcik();
const azMotion = useReducedMotion();
// Liste boşken buton gürültü: tabloya () ulaşmamış ziyaretçiye "0/24"
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
if (liste.length === 0) return null;
return (
<button
type="button"
onClick={cekmeceDegistir}
aria-expanded={acik}
aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`}
title="24'lük tercih listen"
aria-label={`Seçtiklerim — ${liste.length}/${MANUEL_LISTE_MAX} program`}
title="Tablodan seçtiğin programlar"
className="relative inline-flex h-12 cursor-pointer items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97]"
>
<ListChecks className="size-4 text-primary" aria-hidden />
<span className="hidden sm:inline">Listem</span>
<span className="hidden sm:inline">Seçtiklerim</span>
{liste.length > 0 ? (
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
<motion.span

View File

@@ -172,7 +172,7 @@ export function ProgramTablosu({
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini kendi 24&apos;lük listene at.
ile beğendiklerini Seçtiklerim&apos;e at.
</span>
</p>
</div>
@@ -424,10 +424,10 @@ function ProgramSatiri({
onClick={() => onEkle(p)}
title={
listede
? "Zaten listende"
? "Zaten seçtiklerinde"
: doldu
? `Liste dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Listeme ekle"
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Seçtiklerime ekle"
}
className={`inline-flex size-8 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 ${
listede
@@ -441,7 +441,9 @@ function ProgramSatiri({
<Plus className="size-4" aria-hidden />
)}
<span className="sr-only">
{listede ? `${p.isim} listende` : `${p.isim} listeye ekle`}
{listede
? `${p.isim} seçtiklerinde`
: `${p.isim} seçtiklerine ekle`}
</span>
</button>
</TableCell>

View File

@@ -173,6 +173,22 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
// Dilim dağılımı: liste hayal diliminden başladığı için ilk ekran hep
// kırmızı görünür; özet, dengenin sağlıklı olduğunu tek bakışta gösterir.
const dilimSayilari = rapor.tercihler.reduce(
(t, x) => {
t[x.dilim] = (t[x.dilim] ?? 0) + 1;
return t;
},
{} as Record<string, number>,
);
const iller = new Set(
rapor.tercihler
.map((t) => rapor.programlar[t.programId]?.il)
.filter((il): il is string => Boolean(il)),
);
const tekIl = iller.size === 1 ? [...iller][0] : null;
const satir = (
t: RaporSonuc["tercihler"][number],
index: number,
@@ -184,10 +200,16 @@ export function RaporListesi({
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi);
const trendG = trend ? TREND_GORUNUM[trend] : null;
const acikMi = acik.has(t.sira);
const sira2024 = p?.siraGecmisi?.[3] ?? null;
const sira2025 = p?.siraGecmisi?.[4] ?? null;
// Son iki yıldan biri eksikse satır başındaki trend rozeti çelişkili
// okunur ("2025: —" ama "stabil") — rozet ancak iki yıl da doluysa çıkar;
// uzun vadeli trend detaydaki 20212025 çizgisinde durur.
const trendG =
trend && sira2024 != null && sira2025 != null
? TREND_GORUNUM[trend]
: null;
return (
<li
@@ -242,7 +264,7 @@ export function RaporListesi({
</span>
</>
) : (
<span className="text-slate-400">Veri yok</span>
<span className="text-slate-400">Veri eksik</span>
)}
</span>
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
@@ -357,6 +379,35 @@ export function RaporListesi({
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs font-medium">
{(
[
["hayal", "bg-red-500"],
["dengeli", "bg-amber-500"],
["garanti", "bg-emerald-500"],
] as const
).map(([dilim, nokta]) => (
<span
key={dilim}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-700"
>
<span className={`size-2 rounded-full ${nokta}`} aria-hidden />
{dilimSayilari[dilim] ?? 0} {DILIM_ETIKET[dilim].toLocaleLowerCase("tr-TR")}
</span>
))}
<span className="text-slate-400">
Liste hayal diliminden başlar; kırmızı satırlar plana dahildir.
</span>
</div>
{tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Listenin 24 tercihi de tek şehirde (
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak
için birkaç farklı şehir eklemeyi düşünebilirsin.
</p>
) : null}
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
<span className="w-7">#</span>
<span>Program</span>

View File

@@ -6,8 +6,11 @@
import { useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "sonner";
import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita";
@@ -59,6 +62,10 @@ export function SihirbazAdimlar({
onTamamla: (secimler: SihirbazSecimleri) => void;
}) {
const [adim, setAdim] = useState(0);
const [yon, setYon] = useState(1);
const [gecisBasladi, setGecisBasladi] = useState(false);
const [panelRef, panelOlculeri] = useMeasure();
const hareketiAzalt = useReducedMotion();
const [kategoriler, setKategoriler] = useState<string[]>(
baslangicSecimler?.kategoriler ?? [],
);
@@ -89,6 +96,17 @@ export function SihirbazAdimlar({
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
function adimaGit(yeniAdim: number) {
setYon(yeniAdim > adim ? 1 : -1);
setGecisBasladi(true);
setAdim(yeniAdim);
}
const panelYuksekligi =
gecisBasladi && panelOlculeri.height > 0
? panelOlculeri.height
: "auto";
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-1.5">
@@ -97,8 +115,10 @@ export function SihirbazAdimlar({
key={i}
className={
i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-all"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all"
? "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"
}
/>
))}
@@ -107,8 +127,49 @@ export function SihirbazAdimlar({
</span>
</div>
{adim === 0 ? (
<div>
<TransitionPanel
activeIndex={adim}
custom={yon}
variants={{
enter: (gecisYonu: number) => ({
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
opacity: 0,
height: panelYuksekligi,
position: "relative",
}),
center: {
zIndex: 1,
x: 0,
opacity: 1,
height: panelYuksekligi,
},
exit: (gecisYonu: number) => ({
zIndex: 0,
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
opacity: 0,
position: "absolute",
top: 0,
left: 0,
width: "100%",
}),
}}
transition={
hareketiAzalt
? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x
// varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır.
{
opacity: { duration: 0.12 },
x: { duration: 0 },
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}
}
>
<div ref={panelRef} className="px-px pb-2">
<h3 className="font-heading text-lg font-bold">
Hangi alanlara ilgi duyuyorsun?
</h3>
@@ -128,22 +189,32 @@ export function SihirbazAdimlar({
))}
</div>
</div>
) : adim === 1 ? (
<div>
<div ref={panelRef}>
<h3 className="font-heading text-lg font-bold">
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
Haritadan dokunarak seç (en fazla 5 il). İstersen boş bırak, fark
etmez.
En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.
</p>
<div className="mt-4">
<IlSecimHaritasi
iller={facetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
secili={iller}
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
/>
{/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde
güvenilir dokunma hedefi değil); tüm iller program sayısına
göre sıralı listelenir. */}
<div className="mt-4 flex flex-wrap gap-2">
{[...facetler.iller]
.sort((a, b) => b.adet - a.adet)
.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il.toLocaleLowerCase("tr-TR")}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
</div>
{iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400">
@@ -162,32 +233,17 @@ export function SihirbazAdimlar({
))}
</div>
) : null}
{facetler.iller.some((i) => !HARITA_ILLERI.has(i.il)) ? (
<div className="mt-3">
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Harita dışı (KKTC vb.)
</p>
<div className="flex flex-wrap gap-2">
{facetler.iller
.filter((i) => !HARITA_ILLERI.has(i.il))
.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il.toLocaleLowerCase("tr-TR")}
<span className="ml-1.5 text-xs opacity-70">
{i.adet}
</span>
</Cip>
))}
{/* Harita geniş ekranda görsel destek olarak kalır */}
<div className="mt-4 hidden sm:block">
<IlSecimHaritasi
iller={facetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
secili={iller}
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
/>
</div>
</div>
) : null}
</div>
) : (
<div className="space-y-5">
<div ref={panelRef} className="space-y-5">
<div>
<h3 className="font-heading text-lg font-bold">
Üniversite tipi & öncelikler
@@ -240,15 +296,15 @@ export function SihirbazAdimlar({
</div>
</div>
</div>
)}
</TransitionPanel>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setAdim((a) => a - 1)}
onClick={() => adimaGit(adim - 1)}
disabled={adim === 0}
className="cursor-pointer"
className={adim === 0 ? "invisible" : "cursor-pointer"}
>
<ArrowLeft className="size-4" aria-hidden />
Geri
@@ -256,7 +312,7 @@ export function SihirbazAdimlar({
{adim < 2 ? (
<Button
type="button"
onClick={() => setAdim((a) => a + 1)}
onClick={() => adimaGit(adim + 1)}
disabled={!adimGecerli}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>

View File

@@ -12,6 +12,7 @@ import {
import {
motion,
useInView,
useReducedMotion,
type DOMMotionComponents,
type HTMLMotionProps,
type MotionProps,
@@ -84,6 +85,9 @@ export function TypingAnimation({
const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
// Sürekli yazıp silen döngü, hareket azaltılsın diyen kullanıcı için
// rahatsız edici: ilk metin tam ve sabit gösterilir, imleç yanmaz.
const azMotion = useReducedMotion()
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as RefObject<Element>, {
amount: 0.3,
@@ -105,7 +109,9 @@ export function TypingAnimation({
[words, children]
)
// Kaynak metin değişince animasyon baştan başlar — bilinçli reset
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDisplayedText("")
setCurrentWordIndex(0)
setCurrentCharIndex(0)
@@ -115,7 +121,7 @@ export function TypingAnimation({
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (shouldStart && wordsToAnimate.length > 0) {
if (!azMotion && shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay =
delay > 0 && displayedText === ""
? delay
@@ -173,6 +179,7 @@ export function TypingAnimation({
}
}
}, [
azMotion,
shouldStart,
phase,
currentCharIndex,
@@ -223,8 +230,8 @@ export function TypingAnimation({
)}
{...props}
>
{displayedText}
{shouldShowCursor && (
{azMotion ? (wordsToAnimate[0] ?? "") : displayedText}
{shouldShowCursor && !azMotion && (
<span
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
>

View File

@@ -5,6 +5,11 @@ import { appDb, schema } from "./appdb";
import { grantCredits, DENEME_KREDISI } from "./credits";
async function sendMagicLinkEmail(email: string, url: string) {
if (process.env.NODE_ENV !== "production") {
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
// src/lib/dev/actions.ts) — prod'da asla çalışmaz.
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
}
if (!process.env.RESEND_API_KEY) {
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);

View File

@@ -13,6 +13,9 @@ export const DENEME_KREDISI = 5;
// Rapor üretimi/revizyonu başına düşen kredi
export const RAPOR_KREDI = 3;
/** Paketle gelen geri bildirimli liste revizyonu hakkı */
export const MAX_REVIZYON = 2;
export type SpendResult =
| { ok: true }
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };

127
src/lib/dev/actions.ts Normal file
View File

@@ -0,0 +1,127 @@
"use server";
// Dev süperadmin aksiyonları: yalnızca geliştirme ortamında çalışır.
// Panelden (src/components/dev/dev-panel.tsx) çağrılır; kullanıcı durumunu
// (kredi, paket, rapor) kurcalayıp tüm sayfaları gerçek akışta gezmeyi sağlar.
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { appDb, schema } from "@/lib/appdb";
import { auth } from "@/lib/auth";
import { getCurrentUser } from "@/lib/session";
import { URUNLER } from "@/lib/credits";
function devKontrol() {
if (process.env.NODE_ENV === "production") {
throw new Error("Dev paneli yalnızca geliştirme ortamında çalışır.");
}
}
export type DevDurum =
| { girisli: false }
| {
girisli: true;
email: string;
kredi: number;
hasPaket: boolean;
raporVar: boolean;
sira: number | null;
tur: string | null;
};
export async function devDurum(): Promise<DevDurum> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { girisli: false };
const rapor = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
columns: { params: true },
});
const params = rapor?.params as { sira?: number; tur?: string } | undefined;
return {
girisli: true,
email: user.email,
kredi: user.creditBalance,
hasPaket: user.hasPaket,
raporVar: Boolean(rapor),
sira: params?.sira ?? null,
tur: params?.tur ?? null,
};
}
/** Magic link üretir ve (dev'de auth.ts'in yakaladığı) URL'i döner —
* panel bu URL'e giderek anında oturum açar; yeni e-posta = yeni kullanıcı. */
export async function devAnindaGiris(
email: string,
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
devKontrol();
const temiz = email.trim().toLowerCase();
if (!/^\S+@\S+\.\S+$/.test(temiz)) {
return { ok: false, error: "Geçerli bir e-posta gir." };
}
const g = globalThis as { __sonMagicLink?: string };
g.__sonMagicLink = undefined;
await auth.api.signInMagicLink({
body: { email: temiz, callbackURL: "/" },
headers: await headers(),
});
if (!g.__sonMagicLink) {
return { ok: false, error: "Magic link üretilemedi (auth.ts yakalayamadı)." };
}
return { ok: true, url: g.__sonMagicLink };
}
export async function devAyarla(ayar: {
kredi?: number;
hasPaket?: boolean;
}): Promise<{ ok: boolean; error?: string }> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { ok: false, error: "Önce giriş yap." };
await appDb
.update(schema.user)
.set({
...(typeof ayar.kredi === "number"
? { creditBalance: Math.max(0, Math.round(ayar.kredi)) }
: {}),
...(typeof ayar.hasPaket === "boolean"
? { hasPaket: ayar.hasPaket }
: {}),
})
.where(eq(schema.user.id, user.id));
return { ok: true };
}
/** Raporu ve sohbet geçmişini siler — boş durumları test etmek için. */
export async function devRaporSil(): Promise<{ ok: boolean; error?: string }> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { ok: false, error: "Önce giriş yap." };
await appDb
.delete(schema.reports)
.where(eq(schema.reports.userId, user.id));
await appDb
.delete(schema.chatMessages)
.where(eq(schema.chatMessages.userId, user.id));
return { ok: true };
}
/** Ödenmiş sahte sipariş oluşturur — /odeme/sonuc başarı ekranı için. */
export async function devSahteOdeme(): Promise<
{ ok: true; siparis: string } | { ok: false; error: string }
> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { ok: false, error: "Önce giriş yap." };
const id = `dev-${crypto.randomUUID()}`;
await appDb.insert(schema.orders).values({
id,
userId: user.id,
product: "paket",
amountKurus: URUNLER.paket.amountKurus,
credits: URUNLER.paket.credits,
status: "paid",
createdAt: new Date(),
});
return { ok: true, siparis: id };
}

View File

@@ -36,7 +36,8 @@ export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
return {
tercihler,
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme),
uyarilar: rapor.uyarilar,
// Kişisel uyarılar ücretli katmandır; istemciye hiç inmez.
uyarilar: [],
// Program adı, üniversite ve 20212025 taban sıralamaları ham veridir.
programlar: rapor.programlar,
};