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>
This commit is contained in:
101
UX-DUZELTMELER.md
Normal file
101
UX-DUZELTMELER.md
Normal 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")
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -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
18
pnpm-lock.yaml
generated
@@ -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
28
public/yok-logo.svg
Normal 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 |
@@ -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
45
src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +79,41 @@ 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">
|
||||
<p className="font-semibold">Bağlantı gönderildi 📬</p>
|
||||
<p className="mt-1">
|
||||
<span className="font-medium">{email}</span> adresine bir giriş
|
||||
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et —
|
||||
bağlantı 5 dakika geçerli.
|
||||
</p>
|
||||
<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ş
|
||||
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et —
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
66
src/app/gizlilik/page.tsx
Normal 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 açık tutmak için zorunlu çerezler kullanılır;
|
||||
reklam veya izleme çerezi kullanılmaz.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
30
src/app/iletisim/page.tsx
Normal file
30
src/app/iletisim/page.tsx
Normal 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
74
src/app/kosullar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
19
src/app/listem/loading.tsx
Normal file
19
src/app/listem/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 açık. 🎉
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-3">
|
||||
<Database aria-hidden />
|
||||
YÖK Atlas verileri ücretsiz
|
||||
</Badge>
|
||||
{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 2024–2025 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 2024–2025 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 açı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 iç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 açı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 aç
|
||||
Analizleri aç — {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>
|
||||
);
|
||||
}
|
||||
|
||||
96
src/app/listem/revizyon-kutusu.tsx
Normal file
96
src/app/listem/revizyon-kutusu.tsx
Normal 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
13
src/app/loading.tsx
Normal 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
24
src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
106
src/app/page.tsx
106
src/app/page.tsx
@@ -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 açıkk
|
||||
2026 YKS tercih dönemi açı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>
|
||||
))}
|
||||
</dl>
|
||||
<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'ın gerçek verileriyle
|
||||
</strong>{" "}
|
||||
güvenilir sonuçlar
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
19
src/app/sonuc/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,19 +451,33 @@ 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>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => acikDegisti(true)}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Başla
|
||||
</Button>
|
||||
{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)}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
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}
|
||||
/>
|
||||
|
||||
45
src/components/core/transition-panel.tsx
Normal file
45
src/components/core/transition-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
src/components/dev/dev-panel.tsx
Normal file
273
src/components/dev/dev-panel.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"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.
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Wrench, X } from "lucide-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" },
|
||||
];
|
||||
|
||||
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 yenile = useCallback(async () => {
|
||||
try {
|
||||
setDurum(await devDurum());
|
||||
} catch {
|
||||
setDurum(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function paneliAc() {
|
||||
setAcik(true);
|
||||
void yenile();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!acik) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={paneliAc}
|
||||
title="Dev süperadmin paneli"
|
||||
className="fixed bottom-20 left-4 z-50 inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full border border-violet-300 bg-violet-600 px-3 text-xs font-bold text-white shadow-lg transition-transform duration-150 hover:bg-violet-700 active:scale-95 print:hidden"
|
||||
>
|
||||
<Wrench className="size-3.5" aria-hidden />
|
||||
DEV
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const g = durum?.girisli ? durum : null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 left-4 z-50 flex max-h-[72vh] w-80 flex-col overflow-hidden rounded-2xl border border-violet-200 bg-white shadow-2xl print:hidden">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 bg-violet-600 px-4 py-2.5 text-white">
|
||||
<p className="flex items-center gap-1.5 text-sm font-bold">
|
||||
<Wrench className="size-4" aria-hidden />
|
||||
Dev süperadmin
|
||||
{mesgul ? (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAcik(false)}
|
||||
className="cursor-pointer rounded-full p-1 hover:bg-white/20"
|
||||
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-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Oturum
|
||||
</p>
|
||||
{g ? (
|
||||
<div className="mt-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs">
|
||||
<p className="font-semibold text-slate-800">{g.email}</p>
|
||||
<p className="mt-0.5 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 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-200 px-2.5 text-xs outline-none 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"
|
||||
>
|
||||
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-xs 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-lg border border-slate-200 px-2.5 py-2 text-left text-xs hover:border-violet-300 hover:bg-violet-50 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<span className="block font-semibold text-slate-800">
|
||||
{s.ad}
|
||||
</span>
|
||||
<span className="text-[10px] 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-lg border border-emerald-200 bg-emerald-50 px-2.5 py-2 text-left text-xs font-semibold text-emerald-800 hover:bg-emerald-100 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
Ödeme başarı ekranı 🎉
|
||||
<span className="block text-[10px] font-normal text-emerald-700">
|
||||
sahte "paid" sipariş oluşturup /odeme/sonuc'a gider
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Sayfalar */}
|
||||
<section>
|
||||
<p className="text-xs 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-200 px-2.5 py-1 text-[11px] text-slate-700 hover:border-violet-300 hover:bg-violet-50"
|
||||
>
|
||||
{s.ad}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'lük listene at.
|
||||
+ ile beğendiklerini Seçtiklerim'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>
|
||||
|
||||
@@ -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 2021–2025 ç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>
|
||||
|
||||
@@ -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,10 +62,14 @@ export function SihirbazAdimlar({
|
||||
onTamamla: (secimler: SihirbazSecimleri) => void;
|
||||
}) {
|
||||
const [adim, setAdim] = useState(0);
|
||||
const [yon, setYon] = useState(1);
|
||||
const [panelRef, panelOlculeri] = useMeasure();
|
||||
const hareketiAzalt = useReducedMotion();
|
||||
const [kategoriler, setKategoriler] = useState<string[]>(
|
||||
baslangicSecimler?.kategoriler ?? [],
|
||||
);
|
||||
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
|
||||
const [ilArama, setIlArama] = useState("");
|
||||
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
|
||||
baslangicSecimler?.universiteTipi ?? "farketmez",
|
||||
);
|
||||
@@ -89,6 +96,11 @@ export function SihirbazAdimlar({
|
||||
|
||||
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
|
||||
|
||||
function adimaGit(yeniAdim: number) {
|
||||
setYon(yeniAdim > adim ? 1 : -1);
|
||||
setAdim(yeniAdim);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -98,7 +110,9 @@ export function SihirbazAdimlar({
|
||||
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"
|
||||
: i < adim
|
||||
? "h-1.5 w-3 rounded-full bg-primary/50 transition-all"
|
||||
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -107,8 +121,45 @@ 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:
|
||||
panelOlculeri.height > 0 ? panelOlculeri.height : "auto",
|
||||
position: "relative",
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
height:
|
||||
panelOlculeri.height > 0 ? panelOlculeri.height : "auto",
|
||||
},
|
||||
exit: (gecisYonu: number) => ({
|
||||
zIndex: 0,
|
||||
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
}),
|
||||
}}
|
||||
transition={
|
||||
hareketiAzalt
|
||||
? { opacity: { duration: 0.12 } }
|
||||
: {
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
height: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}
|
||||
}
|
||||
>
|
||||
<div ref={panelRef}>
|
||||
<h3 className="font-heading text-lg font-bold">
|
||||
Hangi alanlara ilgi duyuyorsun?
|
||||
</h3>
|
||||
@@ -128,22 +179,65 @@ 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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Birincil seçim yolu: arama + çipler (haritadaki küçük iller
|
||||
mobilde güvenilir dokunma hedefi değil) */}
|
||||
<input
|
||||
type="search"
|
||||
value={ilArama}
|
||||
onChange={(e) => setIlArama(e.target.value)}
|
||||
placeholder="İl ara (ör. İzmir)"
|
||||
aria-label="İl ara"
|
||||
className="mt-4 h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
{(() => {
|
||||
const q = ilArama.trim().toLocaleLowerCase("tr-TR");
|
||||
const sirali = [...facetler.iller].sort((a, b) => b.adet - a.adet);
|
||||
const eslesen = q
|
||||
? sirali.filter((i) =>
|
||||
i.il.toLocaleLowerCase("tr-TR").includes(q),
|
||||
)
|
||||
: sirali.slice(0, 12);
|
||||
const kalan = q ? 0 : facetler.iller.length - eslesen.length;
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{eslesen.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>
|
||||
))}
|
||||
{eslesen.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Bu sıralamada "{ilArama.trim()}" için program
|
||||
bulunamadı.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{kalan > 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
En çok programı olan 12 il gösteriliyor — diğer {kalan} il
|
||||
için arama yaz.
|
||||
</p>
|
||||
) : null}
|
||||
</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 +256,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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
<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 +319,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 +335,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"
|
||||
>
|
||||
|
||||
@@ -105,7 +105,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)
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
127
src/lib/dev/actions.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 2021–2025 taban sıralamaları ham veridir.
|
||||
programlar: rapor.programlar,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user