diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index 91d6a88..7846c41 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -245,10 +245,10 @@ ve kimlik hapları (§4.12) bu kuralın dışındadır. Rozet **sayfa** bölüm ### 4.3 Modal kapatma çarpısı — `DialogContent` varsayılanı, `src/components/ui/dialog.tsx` `absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]` + `` + `sr-only "Kapat"`. Özel yerleşim gerekiyorsa `showCloseButton={false}` ver ve **aynı sınıf setini** kopyala. -Alt sayfada (`Drawer`) aynı sınıf seti `DrawerClose` üstünde kullanılır (§4.10). +Alt sayfada (`AltSayfa`) aynı sınıf seti `AltSayfaKapat` üstünde kullanılır (§4.10). ### 4.4 Kart ve kutu -- Landing/öne çıkan kart: `` + sağ üstte ``. (Kodda hâlâ `hover:border-primary/30 hover:shadow-primary/5`; §2.1 geçiş notu.) +- Landing/öne çıkan kart = **sahne kartı** (§4.31): `flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm transition-[translate,border-color,box-shadow] duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5`; üstte piksel sahne, altında başlık + metin. Eski hâli (ikon kutusu + başlık + metin + köşede `PixelCorner`) 7 Eki 2026'da kaldırıldı (Bilal: "sıkıcı kart tasarımları"); yeni yerde yazılmaz. `PixelCorner` giriş/doğrulama kartlarının köşe süsü olarak kalır. - Sade kutu (form, panel, liste satırı): `rounded-2xl border border-slate-200 bg-card` (+ `p-5`/`p-6`). Küçük iç kutu: `rounded-xl border border-slate-200 bg-slate-50`. Not kutusu: `rounded-lg`. - İkon kutusu: `flex size-11 items-center justify-center rounded-xl bg-primary/10` içinde `size-5 text-primary` ikon. Kart içinde sayının yanında küçük hâli: `size-8 … sm:size-10` + `size-4 sm:size-5` ikon (§4.8). - Gölge: `shadow-sm` varsayılan; hover'da `shadow-lg shadow-orange-500/5`. Başka gölge yok. @@ -318,16 +318,19 @@ yanındaki 2×2). (KKTC uyruklu, açıköğretim ve uzaktan satırları yelpazeye girmez; doluluk bütün satırlar. KKTC üniversitesinin kendi sayfasında kıyas evreni kurumun kendisidir: il/kampüs dışlaması kalkar, ad kuralı kalır — yoksa DAÜ gibi kurumlarda kartlar "henüz taban oluşmadı" der, 6 Eki 2026 QA). - Doluluk yüzdesi boş koltuk varken 100'e yuvarlanmaz, en çok 99 yazar. - - **Bölüm** (`grid grid-cols-2 gap-4 lg:grid-cols-4`, haritası yok): En seçici (sıra + - üniversite adı linkli) · En geniş kapı (ücretsiz devlet örgün programı ≥3 ise devlet - kümesinde, açıklama "Devlet · Bingöl Üni. · 2026 taban sırası"; etiket "Devlette en geniş" - 131 px'te iki satıra kırıldığı için kısa tutulur) · Orta taban (devlet programlarını sıraya - dizince ortadakinin sırası; açıklamada "Devlet · 2021'de 105 bin · gevşedi") · Boş koltuk. + Doluluk yüzdesi boş kontenjan varken 100'e yuvarlanmaz, en çok 99 yazar. + - **Bölüm** (`grid grid-cols-2 gap-4 lg:grid-cols-3`, telefonda tek kalan üçüncü kart + `col-span-2`; haritası yok): En yüksek taban (sıra + üniversite adı linkli) · En düşük taban + (ücretsiz devlet örgün programı ≥3 ise devlet kümesinde, açıklama "Devlet · Bingöl Üni. · + 2026 taban sırası"; etiket "Devlette en düşük" 131 px'te iki satıra kırıldığı için kısa + tutulur) · Boş kontenjan. Orta taban kartı 6 Eki 2026'da kaldırıldı (Bilal: fazla geldi); + hesabı `taban-ozeti.ts`'te duruyor. Etiketlerde "en seçici" ve "en geniş kapı" denmez + ("En yüksek / En düşük taban" öğrencinin kendi dili); kullanıcıya dönük metinde "koltuk" + değil **kontenjan** yazılır. Kartın içinde piksel çizgi/seri **yok** (Bilal, 6 Eki 2026: "çok boğdu"); yön kelimesi yeter. - - **Üniversite** (harita yanındaki 2×2): En seçici bölüm (sıra + bölüm adı linkli) · Orta - taban (lisans programlarının ortası) · Doluluk (`%97`) · Türkiye sırası (URAP; listede - olmayan kurumda Kontenjan). + - **Üniversite** (harita yanındaki 2×2): En yüksek taban (sıra + bölüm adı linkli) · En düşük + taban (aynı biçim) · Doluluk (`%97`) · Türkiye sırası (URAP; listede olmayan kurumda + Kontenjan). - Sayının yanındaki ad (`KartBaglantisi`, `taban-karti.tsx`) karşı sayfaya gider: bölümde üniversiteye, üniversitede bölüme; stil kaynak linkiyle aynı (`font-medium text-primary hover:text-orange-500 hover:underline`). Üniversite adı kısaltılır ("Koç Üni.", "… MYO"). @@ -353,10 +356,26 @@ yanındaki 2×2). en üstte. Bugün her sayfada tek satır var (üniversite: "Akademik göstergeler"; bölüm: "Mezun istihdamı"). -### 4.10 Alt sayfa (Drawer) içeriği -Ayrıntı her zaman **alttan açılan sayfada** (`Drawer handleOnly`, tutamaçlı); ekranın ortasında +### 4.10 Alt sayfa — `AltSayfa`, `src/components/ui/alt-sayfa.tsx` +**Kabuk tek kaynak** (Bilal, 7 Eki 2026: "bottom drawer yerine bottom sheet"): eski vaul çekmecesi +kaldırıldı; yerinde Arc UI "Bottom sheet"ten taşınan `AltSayfa` var. Sayfa parmağı birebir izler: +tutamaçtan, başlık alanından (`AltSayfaSurukle`) ya da içerik en üstteyken gövdeden aşağı +çekince kapanır; yukarı çekince lastik gibi direnir; bırakınca hızını taşıyarak durağına oturur; +kapanırken yakalanıp geri açılabilir. Perde zamanlayıcıyla değil sayfanın konumuyla koyulaşır. +Parçalar: `AltSayfa` (kök) · `AltSayfaTetik` · `AltSayfaIcerik` (perde + yüzey + tutamaç) · +`AltSayfaGovde` (**kaydırılan alan mutlaka budur**; sayfa onun kaydırmasıyla el sıkışır) · +`AltSayfaSurukle` (kaydırmayan başlık alanı) · `AltSayfaBaslik` / `AltSayfaAciklama` · +`AltSayfaKapat`. Yüzey sınıfı bileşenin içindedir (`rounded-t-3xl border border-b-0 +border-slate-200 bg-card shadow-2xl sm:max-w-2xl`, tavan `92dvh`); kullanım yerinde kap sınıfı +**yazılmaz**. Boy içeriğe göredir. `duraklar` (ekran yüksekliğinin oranı) yalnız uzun, kaydırılan +içerikte verilir: program ayrıntısı `[0.6, 0.92]` ile %60'ta açılır, yukarı çekince ya da +kaydırınca tam boya çıkar (§4.19). İçinde kendi sürüklemesi olan öğe (sıralama tutamağı) +`data-alt-sayfa-yoksay` alır. Bildirimdeki düğmeye basmak sayfayı kapatmaz. Hareketi azalt: +kayma yok, yalnız opaklık. + +Ayrıntı her zaman **alttan açılan sayfada** (`AltSayfa`, tutamaçlı); ekranın ortasında açılan modal değil. Çekmece kabı ve çarpı `cta-sira-form.tsx`'teki çekmeceyle birebir: -`DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"`. +`` + §4.3 çarpısı + ``. İçerik kuralları (Apple ayrıntı sayfası): - **Her ekranda ortalı:** başlık `px-10 text-center font-heading text-2xl font-bold`, altında @@ -377,8 +396,8 @@ açılan modal değil. Çekmece kabı ve çarpı `cta-sira-form.tsx`'teki çekme tek istisnası); alt bandı telefonda alt alta dizilir ki listeye en az ~280 px kalsın. - **Rozet yok, ek liste yok** (Bilal, 24 Eyl 2026: "buralarda badge kullanma"): çekmece ve sihirbaz modallarının başlığı üstünde `SectionEyebrow` durmaz; başlık + tek cümle bağlam yeter. - "Saniyede gör" çekmecesi (`cta-sira-form.tsx`) bu düzene çevrildi: `DrawerTitle` ortalı, - `DrawerDescription` bağlam, altında form; "neler alacaksın" çizgili ikon listesi kaldırıldı + "Saniyede gör" çekmecesi (`cta-sira-form.tsx`) bu düzene çevrildi: `AltSayfaBaslik` ortalı, + `AltSayfaAciklama` bağlam, altında form; "neler alacaksın" çizgili ikon listesi kaldırıldı (bağlam cümlesi zaten söylüyor). Sihirbaz adımlarında çekmece başlığı `sr-only`, görünür başlık adımın kendisinde. Denetim: `modal-rozet` (hata). - **Arama motoru uyarısı:** çekmece içeriği sayfa açılırken HTML'de yoktur (kapalıyken @@ -493,13 +512,30 @@ tablosu bu sabitleri kullanır, sınıfı elle yazmaz. - Tür çipi devlet/vakıf için de aynı slate çip; vakıf violet değildir (§2.2). - Tablo başlığı `h-12 text-sm font-semibold text-slate-500`; hücre dolgusu `py-4 pl-4 sm:pl-5`. -### 4.16 Bildirim (toast) — `src/components/ui/sonner.tsx` + `globals.css` -Tema **sitenin temasını izler** (`useTheme().resolvedTheme`, §2.3), sistemin değil: sistem koyu -temadayken sonner açıklamayı beyaza yakın griye çevirip beyaz zeminde okunmaz ediyordu (Bilal, -24 Eyl 2026: "toast açıklaması okunmuyor"). Kutu `rounded-2xl border-slate-200`; başlık `font-semibold text-slate-900`; açıklama +### 4.16 Bildirim yığını (toast) — `src/components/ui/bildirim.tsx` +Bilal (7 Eki 2026): "toastlarımız için Toast stack kullanalım." `sonner` kaldırıldı; yerinde Arc UI +"Toast stack"ten taşınan `BildirimYigini` (layout'ta bir kez) ve aynı çağrı biçimli `toast` var +(`toast.success(başlık, { description, action, id, duration })` — yalnız import yolu değişti). +Bildirimler **üst ortada** birikir (altta çekmecenin çağrı bandı ve telefonda klavye var): yenisi +yukarıdan iner, eskiler arkasına saklanıp altından 12 px şerit gösterir, en çok üçü görünür; +üzerine gelince ya da dokununca yığın listeye açılır ve süre durur; sağa kaydırınca kapanır. +Aynı `id` yeni bildirim açmaz, olanı yerinde günceller (metin dönüşür). Renkler palet token'ı +olduğundan tema kendiliğinden doğru (eski sorun: sonner sistem temasına bakıp açıklamayı okunmaz +ediyordu — Bilal, 24 Eyl 2026). Tür ikonu: başarı `emerald-600`, bilgi `primary`, uyarı +`amber-600`, hata `red-600`, sürüyor dönen `Loader2 text-primary`; tür adı ekran okuyucuya yazıyla söylenir. Kutu `rounded-2xl border-slate-200`; başlık `font-semibold text-slate-900`; açıklama `text-slate-600`; aksiyon düğmesi üst çubuktaki koyu hap (`h-9 rounded-full bg-slate-900`, yazı `slate-50`, hover `slate-700`). Metin §10.4: ne oldu + ne yapılacak; kilometre taşı toast'ları (`program-ekle-butonu.tsx`) tek cümle açıklama + tek aksiyon taşır. +**İç düzen — tek hiza (Arc'ın düzeni; Bilal, 7 Eki 2026: "Arc'taki gibi hizalı görünüm +olmuyor").** İkon, başlığın ilk satırı, aksiyon hapı ve çarpı aynı 36 px'lik şeridin ortasına +oturur: içerik `flex items-start gap-3 py-1.5 pr-1.5 pl-4`; ikon kutusu `h-9 w-5` (ikon `size-5`); +metin `py-2 text-sm leading-5`; aksiyon hapı ve çarpı `h-9` / `size-9`, sağda `gap-1` tek grup. +Tek satırlık bildirim 48 px'tir ve her şey kutunun tam ortasındadır; çok satırlıda ikon ve +çarpı ilk satırla hizalı kalır. **Aksiyonun yeri içeriğe göre:** açıklama yoksa hap başlığın +yanında, sağda ("Çıkarıldı · Geri al" tek satır); açıklama varsa metnin altında, metinle aynı +sol hizada (`mt-2.5`) — yanında durursa Türkçe açıklamayı üç-dört satıra sıkıştırır. Eksi kenar +boşluğuyla hizalama yapılmaz (eski hâlde çarpı ilk satırdan 4 px aşağıdaydı); yeni bir öğe +eklenirse o da 36 px'lik şeride oturtulur. ### 4.17 Şehir çipleri — `SehirCipleri`, `src/features/katalog/components/sehir-cipleri.tsx` Şehre göre üniversite gezinmesi (`/universiteler` üstü ve `/universiteler/[il]` altı). @@ -577,7 +613,7 @@ telefonda tam genişlik), "Şehre göre" çipleri (sayfa içi `#sehir-…` çapa Bilal (24 Eyl 2026): "bu kısımlar design md'ye uygun yap, belirt ki bir daha bu hata olmasın". Panel ortada açılan `Dialog` ve `text-xs` hücreli bir tabloydu; iki kural birden çiğnenmişti. **Kural:** program satırından açılan her ayrıntı (netler, akademik göstergeler, -kontenjan geçmişi) §4.10 **alt sayfadır** — `Drawer handleOnly`, kap ve çarpı `cta-sira-form.tsx` +kontenjan geçmişi) §4.10 **alt sayfadır** — `AltSayfa`, kap ve çarpı `cta-sira-form.tsx` çekmecesiyle birebir, başlık `px-10 text-center font-heading text-2xl font-bold`, altında tek cümle bağlam `text-sm text-slate-500`. İçindeki tablo §4.15'tir: başlık `SATIR_BASLIK`, hücre `text-sm` (gövde `text-xs`'e inmez, §3), sayılar `font-sans tabular-nums`, **en yeni yıl** koyu @@ -634,7 +670,21 @@ genişletilebilir olmalı; UI için prompt-kit kullan." `animate(izgara, { "--sohbet-kolon": "" })` ile ızgara genişliğinin %60'ına (en çok 760 px) büyür; liste ve harita daralan sol sütunda kendini yeniden akıtır. Telefonda (lg altı) yalnız boy uzar. `Minimize2` geri alır; sohbet durumu (useChat, kaydırma) korunur. Hareketi azalt: geçişsiz. - Perde, sabit konum, ayrı `Dialog`/`Drawer` **yok** — sohbet yeniden kurulmaz, sayfa zıplamaz. + Perde, sabit konum, ayrı `Dialog`/`AltSayfa` **yok** — sohbet yeniden kurulmaz, sayfa zıplamaz. +- **Dar hâlin boyu — sayfa en üstteyken de bulanık şeridin üstünde biter** (Bilal, 7 Eki 2026: + "yüksekliğini alttan kısalım, progressive blur üzerine geliyor, sığmıyor"). Sınıf tek yerde: + `SOHBET_DAR_BOY` (`sohbet-client.tsx`) = `h-[calc(100dvh-23.75rem)] min-h-[340px] + sm:h-[calc(100dvh-24.75rem)] lg:h-[calc(100dvh-21.5rem)]`. Düşülen pay = panelin sayfa en + üstteykenki üst boşluğu (sezon bandı + site başlığı + `main` py-12 + ızgara mt-8; **ölçüldü**: + telefonda 251, 768 px'te 267, 1280 px'te 217 px) + 7rem şerit + 1rem nefes. Eski `18rem` + sezon bandını saymıyordu. Paketsizdeki 480 px'lik panel aynı sayıları `max-h` olarak taşır. + Sezon bandı ya da başlık boyu değişirse bu üç sayı yeniden ölçülür (§9.2). +- **Elle boyutlandırma ve telefonda sıra** (Bilal, 7 Eki 2026): geniş ekranda liste ile sohbet + arasındaki boşlukta `BolmeTutamaci` durur (§4.25); sürükleyince aynı `--sohbet-kolon` değişkeni + değişir (sohbet 320–820 px, liste en az 420 px), çift tık varsayılana döner. "Genişlet" düğmesi + elle verilen genişlikten devam eder; "Daralt" varsayılana döner. **Telefonda (lg altı) danışman + en üsttedir**: sütun kabı `max-lg:contents`, sohbet `max-lg:order-first`, paketsizdeki paket + kartı `max-lg:order-last` — harita ve liste sohbetin altında akar. - **Bağlantı hapları** (`sohbet-baglanti.tsx`, Bilal 6 Eki 2026: "chat link verdiğinde kod gibi veriyor"): danışman cevabındaki site içi adres (`/rehber/…`, `/universite/…`, `/bolum/…`, `/universiteler/`, `/paket`, `/listem`) prompt-kit `Source` kalıbında **küçük satır içi hap** @@ -686,6 +736,16 @@ genişletilebilir olmalı; UI için prompt-kit kullan." zaman yazılıdır (§2.2). Sohbet ilerlese de kalır. Yeni bir liste uyarısı gerekince `listeUyarilari`'na eklenir, sayfaya yeni kutu açılmaz. +- **Liste revizyonu sohbette** (Bilal, 7 Eki 2026: "revize sistemi tamamen chate taşınsın"; + geri bildirim: eski kutudan "ilk 5 X ilinden olsun" denince liste değişmiyordu — not modele + serbest metin gidiyor, sıra ve havuz koddan belirleniyordu). `/listem`deki revizyon kutusu + kaldırıldı; başlıktaki "Revize et" düğmesi sohbet kutusunu odaklar (`sohbetiOdakla`). Danışman + belirsiz isteği **soru formuyla** netleştirir (`soru_sor` → §4.33 `SohbetAnketi`, balonun + altında sade kutu), sonra `liste_revize` aracıyla **yapısal kural** verir (il/alan/tip farkı, + "ilk N" ölçütü, çıkar/ekle, iskelet; `lib/revizyon-kurali.ts`); liste koddan yeniden kurulur, + sayfa `router.refresh()` ile yenilenir, hap "Listeni yeniden kurdum" olarak kalır. Form cevabı + kredi harcamaz. Kayıtlı geçmişte form ve revizyon sonucu `chat_messages.meta`dan geri kurulur. + ### 4.21 Sayı rulosu — `SayiRulo`, `src/components/ui/sayi-rulo.tsx` Ekranda **değişen** sayı (üst çubuk rozeti ve kredi hapı, "Listende 5/24", çekmece sayacı, danışman kredi rozeti, "40 / 312 program", üst şeridin sezon geri sayımı, giriş formunun @@ -704,7 +764,160 @@ Tek seçimli hap grubunda seçili vurgu yeniden boyanmaz, tek yüzey olarak seç **kayar** (`layoutId`). `ayrik`: kenarlıklı ayrı haplar + mavi dolu vurgu (puan türü çipleri — hero ve CTA formu). `kapsul`: tek kapsülde koyu hap, yazı `text-slate-50` (tema seçici). Yay §6; hareketi azalt'ta vurgu yerinde belirir. Kaynak Arc UI "Chip group" (6 Eki 2026). Çok seçimli -çipler (sihirbaz alan/il) bu desene girmez — orada her çip kendi başına yanar. +çipler (sihirbaz alan/il) bu desene girmez — onlar §4.24 `SecimCipi`'dir (seçilince tik büyür). + +### 4.23 Kaydırma eylemi — `KaydirmaGrubu` + `KaydirmaSatiri`, `src/components/ui/kaydirma-eylemi.tsx` +Bilal (7 Eki 2026): "Arc'taki swipe action'ı bizdeki listeye getir." "Kendi Listem" satırı **sola +kaydırılınca** altından "Çıkar" eylemi açılır (80 px, `bg-red-500 text-white`, ikon + yazı); +sonuna kadar çekilince eylem satırın tamamını kaplar ve bırakınca uygulanır, dokunmatikte eşikte +kısa titreşim verir. Aynı anda tek satır açık kalır; başka yere dokunmak ya da Escape kapatır. +Çıkarma §10.5 gibi geri alınabilir bildirimle biter. **Eylem, satırda görünen bir düğmeyle de +yapılabilmelidir** (listede `CircleMinus`): klavye ve ekran okuyucu yolu odur, kaydırılan kopya +`aria-hidden`. Dikey sıralama tutamağı ayrı kalır. Tonlar: `tehlike` (kırmızı; etiket sonucu +söyler), `vurgu` (mavi), `notr` (slate). Kaynak Arc UI "Swipe actions"; "Diğer eylemler" menüsü +alınmadı. Yeni bir listede kaydırma gerekirse bu bileşen kullanılır, elle `drag="x"` yazılmaz. + +### 4.24 Seçim çipi ve çip grubu — `SecimCipi` + `CipGrubu`, `src/components/ui/cip-grubu.tsx` +Bilal (7 Eki 2026): "chip group'u pills seçimi olan yerlerde kullanalım." Çok seçimli hap +seçimlerinin tek kaynağı (sihirbazın alan, şehir, üniversite tipi, öncelik ve özel kontenjan +çipleri). Sınıf §4.17 çipi (44 px; `kucuk` 40 px; seçili `border-primary bg-primary text-white`; +`ton="bilgi"` pasifte mavi ton). Seçilince başta **tik kendini çizerek büyür**, yazı yana kayar, +çipin kenarı yayla izler, komşular akışla yer açar — seçili hâl renk + tik'tir (§2.2). `CipGrubu` +gruba ad verir (`etiket`) ve ok tuşlarıyla çipten çipe geçirir. Kaynak Arc UI "Chip group"; +komşuları layout projeksiyonuyla kaydırma alınmadı (kayan alt sayfa ve ölçülen sihirbaz paneli +içinde erken ölçüm satırları kaydırıyordu), "+N daha" katlaması da yok. Tek seçimde kayan vurgu +§4.22. + +### 4.25 Bölme tutamacı — `BolmeTutamaci`, `src/components/ui/bolme-tutamaci.tsx` +Bilal (7 Eki 2026): "Resizable panels ile danışmanın bulunduğu sayfada ölçeklendirme +yapılabilsin." Yan yana iki alanın arasındaki 24 px'lik boşlukta duran ayırıcı: dinlenirken silik +bir tutamaç (`h-10 w-1.5 bg-slate-300`), üzerine gelince / odakta / sürüklerken turuncu ve uzun +(§2.1), yanında ince turuncu çizgi. İşaretçiyi birebir izler, sınırın ötesinde lastik gibi direnir +ve bırakınca sınıra döner. `role="separator"`: ok tuşları 16 px (Shift 64 px), Home / End sınırlar, +çift tık varsayılan. Genişliği çağıran tutar (`oku` / `yaz` / `sinirlar`); `/listem`'de ızgaranın +`--sohbet-kolon` değişkeni (§4.20). Yalnız `lg` ve üstünde görünür. Kaynak Arc UI "Resizable +panels"; sabit boylu kap ve bölmeyi tamamen kapatma alınmadı (sayfa kayıyor, sohbet yapışkan). + +### 4.26 Yazı dönüşümü — `YaziDonusumu`, `src/components/ui/yazi-donusumu.tsx` +Kısa bir ifade yerinde bir sonrakine dönüşür: ortak harfler yeni yerine süzülür, yeniler +netleşerek gelir, gidenler bulanıp söner, genişlik yayla izler. **Şu an sayfada kullanılmıyor:** +ana sayfa başlığındaki dönen vurgu ("gerçek veriyle" → "risk hesabıyla" → "gerekçesiyle") +7 Eki 2026'da geri alındı (Bilal: "diğer ana sayfa bileşenleri eski hâline gelsin"); başlık yine +sabit "gerçek veriyle" yazar. Bileşen dosyası duruyor. Kaynak Arc UI "Text morph". Yeni kullanım: yalnız **birkaç harfi değişen kısa +durum yazısı**; paragraf ya da uzunluğu çok değişen metin dönüştürülmez (Bilal'in 24 Eyl 2026'da +kaldırdığı "yazılıp silinen satır" geri gelmez). + +### 4.27 Kart destesi — `KartDestesi`, `src/components/ui/kart-destesi.tsx` +Kartlar tek tek sorulur: üstteki kart sağa/sola sürüklenip fırlatılır (parmağın hızını taşır), +arkadakiler o uzaklaştıkça kalkar; aynı karar düğmelerle ve ok tuşlarıyla verilir, "Geri al" kartı +çıktığı yerden getirir. Kart `rounded-3xl border border-slate-200 bg-card`, boyu `--kart-boyu` +(19rem); düğmeler 44 px hap — sol ikincil (hover §2.1), orta sessiz, sağ **mavi dolu** (turuncu +yalnız funnel CTA'sı). Sürüklerken beliren damgalar hap; onay damgası mavi tonda (yeşil risk +dilindedir, burada kullanılmaz). Kaynak Arc UI "Card stack". +- **"Bu program listene girer mi?" demosu** (`deste-demosu.tsx`, veri `landing-verisi.ts` + `ornekProgramlar`): örnek sıralamaya (Sayısal 85.000) göre **gerçek** yedi devlet programı. + Kartta künye şeridi (il · Devlet), ad, üniversite, iri taban sıralaması (§1.1) ve + `RiskEtiketi`. Uydurma sayı yok; veri okunamazsa çizilmez. Bitince tek turuncu CTA forma + götürür. **Ana sayfadan çıkarıldı (7 Eki 2026)** — Bilal: "bunu oyunlaştırmada ilerde + kullanacağız". Dosyalar duruyor; şu an hiçbir sayfada çizilmiyor. + +### 4.28 Veri akışı — `VeriAkisi`, `src/features/pazarlama/components/veri-akisi.tsx` +Ana sayfa "Nasıl çalışır" bölümünde, üç adımın (§4.32) altında: ortada KolayTercih işareti, +çevresinde altı girdi; her girdiden merkeze **düz çizgi** iner, üzerinde piksel kare akar. **Solda +öğrencinin üç seçimi** (turuncu ikon ve kare), **sağda YÖK Atlas'ın üç verisi** (mavi ikon ve +kare); ayrımı etiket ve yan taşır, renk eşlik eder (§2.2). Akış CSS'tir (`kt-akis`, globals.css), +yalnız ekrandayken oynar; hareketi azalt'ta kareler durur. Esin React Bits "Center Flow"; kod +bizim — parlama ve bulanıklık yok, akış içeri doğru. Yeni bir girdi eklenirse sihirbaz/havuz +kurallarıyla aynı olmalı (`/meraklisina` şemasıyla çelişmez). +- **Çizgi düz, karolar beyaz** (Bilal, 7 Eki 2026: "aradaki yerler solid çizgi olsun, etrafındaki + kartların bg'si ve görünümü daha iyi olabilir"). Çizgi `var(--color-slate-300)`, 1,5 px + (`vectorEffect="non-scaling-stroke"`), karonun merkezinden işaretin merkezine çekilir ve + karoların **altında** kalır; kesik/noktalı çizgi yazılmaz. Girdi karosu + `size-14 rounded-2xl border border-slate-200 bg-card` + `size-6` ikon; ortadaki karo + `size-24 rounded-3xl border border-slate-200 bg-card`. Renkli soluk zemin (`bg-orange-50/60`, + `bg-primary/10`) kaldırıldı: karo dolu olmalı ki çizgi içinden görünmesin. Gölge yok (§6). +- **Kartsız, zeminsiz; bilgiler iki yanında, onlar da kartsız** (Bilal, 7 Eki 2026: "kartın + dışında, arka plansız, sağında solunda da alttaki bilgiler olacak şekilde"; aynı gün: "hero'daki + gibi ferah"). Geniş ekranda (lg+) üç sütun (`lg:grid-cols-[minmax(0,1fr)_22rem_minmax(0,1fr)]`, + xl'de orta 24rem, dikeyde ortalı): **solda dürüstlük notu** — `SectionEyebrow` + `h3` + (`font-heading text-2xl font-bold text-balance`, ikinci yarısı hero başlığındaki vurgu: + `font-bricolage font-bold tracking-tighter text-primary italic`) + `text-base` paragraf, sola + yaslı, `max-w-xs`; **ortada animasyon**; **sağda üç sayı** alt alta, sağa yaslı (YÖK Atlas + girdilerinin yanında — sayılar o verinin sayılarıdır). Sayı §1.1: `font-heading text-4xl + font-bold leading-none tracking-tight text-primary sm:text-5xl`, birim ("yıl") §3'teki gibi + `font-sans text-base font-semibold text-slate-400`, etiket `text-sm font-semibold + text-slate-500`. Çerçeve, zemin, ayırıcı çizgi ve sayının yanında piksel süs **yok**. + lg altında tek sütun, her şey ortalı: animasyon → sayılar (sm'de üçü yan yana) → dürüstlük notu. +- **İşaret site başlığındaki logonun animasyonunu oynar** (`AnimasyonluMark`, varsayılan "rota": + kareler sırayla birbirinin yerine süzülür). Başlıkta üzerine gelince, burada ekrandayken + sürekli (`oynat`); ekrandan çıkınca kareler yerine oturur. Nabız + tüm karoyu döndüren eski + hâl (`kt-akis-donus`) yanlıştı ve kaldırıldı (Bilal, 7 Eki 2026). Hareketi azalt'ta durağan. + +### 4.29 Program duvarı — `ProgramDuvari`, `src/features/pazarlama/components/program-duvari.tsx` +**Şu an sayfada kullanılmıyor:** ana sayfa "Sorun" bölümünden 7 Eki 2026'da çıkarıldı (Bilal: +"diğer ana sayfa bileşenleri eski hâline gelsin"); bölüm yine yalnız üç kartla açılır. Dosya +duruyor. Tarif: +"Binlerce ihtimal"in resmi: aramadaki mini pencere dilinde +(`rounded-2xl`, üç noktalı `h-9` çubuk, §4.20) bir tuvalde piksel kareler satır satır akar — +her kare bir program; aralarında **tam 24 turuncu kare** sırayla belirir ve yerinde kalır (senin +listen). İmleç yaklaştığı satırları hızlandırır. Izgara hero'nun ölçüsü (hücre 10, nokta 7.4, +köşe 2), mavi doku opaklıkları aynı; renk CSS'ten okunur, tema değişince yeniden çizilir. Tek +``, ekrandayken ~30 fps; hareketi azalt'ta durağan. Anlamı altındaki cümle söyler. Esin +React Bits "Squares Terminal"; koyu CRT ekranı, parlama ve tüp eğimi **alınmadı** (koyu yüzey +yalnız kapanışta, §1.2; parlama gölgesi yok, §2.2). + +### 4.30 Süzülen haplar — `SuzulenHaplar`, `src/features/pazarlama/components/suzulen-haplar.tsx` +Ana sayfa "Katalog ücretsiz" bölümü: en çok aranan bölüm ve şehir sayfalarına giden haplar +(§4.12 kimlik hapı, `GraduationCap` / `MapPin`, hover §2.1; her üçüncü hap bir kademe iri). +Bölüm ekrana girince haplar yukarıdan hafif eğik düşüp oturur (tek, hafif sekme — düşen nesne, +§6); imleç gezdikçe her hap kendi derinliğinde en çok 18 px süzülür, arkadaki piksel kareler +ters yöne kayar. Haplar akışla dizilir (375 px'te üst üste binmez) ve SSR'da görünür linktir; +yalnız katalogda sayfası olan adlar çizilir. Dokunmatikte süzülme yok; hareketi azalt'ta yalnız +belirir. Esin React Bits "Parallax Pills"; kod bizim. + +### 4.31 Piksel sahne ve sahne kartı — `PixelSahne`, `src/components/pixel-decor.tsx` +Bilal (7 Eki 2026): "landing'de en beğendiğim yer hero'daki Türkiye kısmı; buradaki gibi ferah ve +marka kimliğimizi yansıtacak şekilde sıkıcı kart tasarımlarını da güncelle." Hero haritasının +dili (hücre 10, nokta 7.4, köşe 2, altı opaklık, imleçle turuncuya ısınma, nabız atan turuncu +vurgu) küçük resimlere taşınır. Sahne, harita gibi satır satır bir ızgaradan çizilir: `#` doku +pikseli, `-` silik piksel, `o` turuncu vurgu. +- **Sahne süs değildir, kartın söylediğini çizer.** Ana sayfa "Sorun" kartları: "24 tercih, + binlerce ihtimal" → yoğun piksel alanı, içinde **tam 24** turuncu kare; "Geçen yılın puanı + yetmiyor" → her yıl başka boyda altı çubuk, yalnız sonuncusunun tepesi turuncu; "Danışman lüks + haline geldi" → basamak basamak tırmanan merdiven, tepesi turuncu. Ne çizeceğini bilmediğin + karta sahne koyma; rastgele doku sahne değildir. Sayı söyleyen sahnede (24) kare sayısı tutar. +- **Sahne kartı** (§4.4 sınıfı): üstte sahne yuvası `border-b border-slate-100 bg-slate-50 px-6 + py-8` (hero'yla aynı zemin), altında `p-6` içinde `h3` (`font-heading text-xl font-bold + text-balance`) + `mt-2 text-base leading-relaxed text-slate-600`. Kartta ikon kutusu ve köşe + pikseli yok — sahne ikisinin yerini tutar. Izgaralar 24×8; üç kart aynı boyda sahne taşır. +- **Zemin değildir.** §10.8'deki "piksel zemin yalnız CTA yüzeylerinde" kuralı sürer: sahne + yazının arkasına serilmez, kendi yuvasında durur ve üstüne metin gelmez. Çekmece, modal ve + katalog kartlarına sahne konmaz (§4.8: özet kartının içinde piksel seri "çok boğdu"). +- `ton="dolgun"` aynı dokunun koyu hâlidir (opaklık 0,64–1); okunması gereken şekiller (rakam) + için. Sahne `aria-hidden`'dır; anlamı başlık taşır. + +### 4.32 Adım rakamları — `PixelRakam`, `src/components/pixel-decor.tsx` +Ana sayfa "Nasıl çalışır"ın üç adımı **kartsızdır** (Bilal, 7 Eki 2026, §4.31'deki istek): adım +numarası hero'nun piksel diliyle yazılmış iri rakamdır (5×7 ızgara, `h-24`, `ton="dolgun"`), +altında `h3` (`mt-6 font-heading text-xl font-bold text-balance`) ve `mt-2 max-w-sm text-base +leading-relaxed text-slate-600`; her şey ortalı. `md`'den itibaren rakamlar düz bir çizgiyle +birbirine bağlanır (`h-0.5 rounded-full bg-slate-200`, rakamın ortasında; veri akışındaki düz +çizgiyle aynı dil). Telefonda adımlar alt alta, çizgisiz. Yapı `ol > li`; rakam `aria-hidden`, +başlığın başında `sr-only` "1. adım:". Turuncu numara rozeti, ikon ve kart çerçevesi kaldırıldı. +Yeni bir rakam gerekirse ızgarası `RAKAMLAR`'a eklenir (bugün 1–3). + +### 4.33 Soru formu — `questionnaire.tsx` (shadcn Questionnaire) + `SohbetAnketi`, `sohbet-anketi.tsx` +shadcn'in `questionnaire` kayıt öğesi (ilkel `@shadcn/react/questionnaire`) sitenin diline çevrildi: +`muted / input / foreground` token'ları yok. Sorular **tek tek** gösterilir; sağ üstte ilerleme +`1 / 3` (`text-xs font-semibold text-slate-500 tabular-nums`). Seçenek satırı §4.4 sade kutu + +§4.17 çip dili: `min-h-11 rounded-2xl border border-slate-200 bg-card px-3.5 py-2.5 text-sm +font-semibold text-slate-700`, hover tek turuncu, seçili `border-primary bg-primary text-white` ve +başındaki kutu tik olur (seçili hâl renk + tik, §2.2); sağda klavye harfi küçük kutuda. Serbest cevap +alanı §4.6 hapı (`h-11 rounded-full`). Düğmeler sitenin `Button`'u (`sm`): Geri (outline), Atla +(ghost; yalnız zorunlu olmayan soruda), İleri / "Cevapları gönder" (default). Hata `text-sm +font-medium text-red-600`. Kapanmış form tek satır: "Formu cevapladın." (yeşil tik) ya da "Form +kapandı; isteğini yazarak söyleyebilirsin." Yeni bir anket/soru akışı gerekince bu bileşen kullanılır; +radio/checkbox'lı yeni form tasarlanmaz. ## 5. Yerleşim @@ -764,11 +977,15 @@ sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerek ## 6. Derinlik, katman ve hareket -Derinlik gölgeyle değil **piksel katmanı ve hareket farkıyla** verilir: başlık bloğu gövdeden hafif hızlı akar (`Parallax strength={16}` başlık, `7` içerik, `26` ayraç). Kartlar hover'da 4 px yükselir. Modal arka planı `bg-black/10` + hafif blur. Yığın: içerik < sabit bar < çekmece/modal (`z-50`). +Derinlik gölgeyle değil **piksel katmanı ve hareket farkıyla** verilir: başlık bloğu gövdeden hafif hızlı akar (`Parallax strength={16}` başlık, `7` içerik, `26` ayraç). Sahne kartları (§4.31) hover'da 4 px yükselir. Modal arka planı `bg-black/10` + hafif blur. Yığın: içerik < sabit bar < çekmece/modal (`z-50`). Hareket kuralları (Apple, `motion/react`): - Varsayılan yay: `{ type: "spring", bounce: 0, duration: 0.35 }` — sekmesiz, sakin. Sekme yalnızca kullanıcı bir şeyi fırlattıysa (sürükle-bırak). +- Arc UI'dan taşınan bileşenlerin yayları, eğrileri ve lastik direnci tek dosyada: + `src/lib/hareket.ts` (`YAY.akici` panel/yığın, `YAY.donusum` biçim, `YAY.birakma` parmak + bıraktıktan sonraki tek hafif sekme). Yeni bir Arc bileşeni yay uydurmaz, buradan alır; + sınıflar Arc'ın CSS modülünden değil bu belgeden gelir. - Giriş ve çıkış **aynı yoldan** (aşağıdan geldiyse aşağı gider). - Basınca anında tepki: `active:scale-[0.97]` (buton), `[0.99]` (geniş satır), `[0.96]` (çarpı). - Hover'da ok `translate-x-0.5` kayar; kart yükselmesi yalnız landing kartında. @@ -805,7 +1022,7 @@ Hareket kuralları (Apple, `motion/react`): tasarlanır. Ortalı ↔ sola yaslı geçişi `lg`'dedir (§5.2). - Dokunma hedefi en az 44 px (`h-11`, `size-11`); satır içi küçük buton `h-9` yalnızca masaüstü tablo aksiyonlarında. - Başlık ölçeği bir kademe düşer (`text-3xl` → `sm:text-5xl` alt sayfa h1), gövde 16 px altına inmez. -- Modal genişliği `max-w-[calc(100%-2rem)]`; ayrıntı her zaman çekmecede (`Drawer`), alttan açılır. +- Modal genişliği `max-w-[calc(100%-2rem)]`; ayrıntı her zaman alt sayfada (`AltSayfa`, §4.10), alttan açılır. - Çekmece içeriği 375×812'de kaydırmasız sığar (§4.10). - `prefers-reduced-motion`: her animasyonun sade karşılığı var (`motion-reduce:animate-none`, `.kt-unblur-sade`, `useReducedMotion`). @@ -866,7 +1083,7 @@ Bir ekranı dönüştüren ajan o ekrandaki uyarıları sıfırlar ya da neden k ### 9.5 Bitirmeden `pnpm tasarim:denetim` sıfır hata (uyarılar raporda listelenir) + 375 px'te tek bakış (taşma, üst üste binme, dokunma hedefi, çekmece tek ekran) + §9.2 ölçümleri. -- Yeni ayrıntı paneli mi? `Dialog` değil `Drawer` (§4.10, §4.19). İçinde tablo mu? §4.15 +- Yeni ayrıntı paneli mi? `Dialog` değil `AltSayfa` (§4.10, §4.19). İçinde tablo mu? §4.15 sınıfları, hücre `text-sm`, `text-xs` yok. Buton mu? Gölge yok, hover tek turuncu (§2.1, §6). ## 10. Samimi ton, ilerleme ve kutlama (öğrenci ekranları) @@ -913,7 +1130,7 @@ Araştırma ve kaynaklar: `docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md` ### 10.6 Anlamsal yapı - Sayfa başına tek `h1`; bölümler `h2`. Başlıklarda `text-balance`. - Odak almayan durum değişikliği ekran okuyucuya duyurulur: `role="status"` (liste sayacı, - sihirbaz adımı, bekleme). Bildirimler (sonner) bunu kendisi yapar. + sihirbaz adımı, bekleme). Bildirim yığını (§4.16) bunu kendisi yapar. - Açılır satır: `aria-expanded` + `aria-controls` (APG disclosure). - Site geneli "İçeriğe geç" linki `layout.tsx`'te; içerik kabı `#icerik`. - Çekmece kaydırma alanı `overscroll-contain`. @@ -988,6 +1205,7 @@ karar. Aynı sayfadaki iki band farklı `seed` alır. **Piksel zemin yalnız CTA ve başlık yüzeylerinde — `PikselZemin`, `src/components/piksel-zemin.tsx`** +(Ana sayfa kartlarındaki piksel **sahne** zemin değildir; kendi yuvasında duran resimdir, §4.31.) (Bilal, 24 Eyl 2026: önce "her CTA benzeri kısmın arkasında olmalı", sonra "/sonuc'taki kartın arkasına da piksel koymuşsun; pikseller CTA'larda olacak sadece"). CTA yüzeyi = çağrı yüzeyi: mavi `CtaBandi`, koyu kapanış kartı `PikselCtaYuzeyi`, arama penceresindeki koyu kart. diff --git a/drizzle/0006_little_luckman.sql b/drizzle/0006_little_luckman.sql new file mode 100644 index 0000000..cc90c72 --- /dev/null +++ b/drizzle/0006_little_luckman.sql @@ -0,0 +1 @@ +ALTER TABLE `chat_messages` ADD `meta` text; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..43d7948 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1018 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4b37fad3-eb2a-48b4-9d98-38bc311caa30", + "prevId": "09682d29-2bdc-4235-b931-9057baf5657a", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_message_id": { + "name": "client_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_client_message_id_unique": { + "name": "chat_messages_client_message_id_unique", + "columns": [ + "client_message_id" + ], + "isUnique": true + }, + "chat_user_created": { + "name": "chat_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credit_ledger": { + "name": "credit_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delta": { + "name": "delta", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ledger_reason_ref": { + "name": "ledger_reason_ref", + "columns": [ + "reason", + "ref_id" + ], + "isUnique": true + }, + "ledger_user": { + "name": "ledger_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "danisman_profil": { + "name": "danisman_profil", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alanlar": { + "name": "alanlar", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "danisman_profil_user_id_user_id_fk": { + "name": "danisman_profil_user_id_user_id_fk", + "tableFrom": "danisman_profil", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_kurus": { + "name": "amount_kurus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "iyzico_token": { + "name": "iyzico_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iyzico_form_content": { + "name": "iyzico_form_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iyzico_payment_id": { + "name": "iyzico_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "orders_user": { + "name": "orders_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "orders_iyzico_token": { + "name": "orders_iyzico_token", + "columns": [ + "iyzico_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "orders_user_id_user_id_fk": { + "name": "orders_user_id_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_locks": { + "name": "report_locks", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "report_locks_user_id_user_id_fk": { + "name": "report_locks_user_id_user_id_fk", + "tableFrom": "report_locks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reports": { + "name": "reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "params": { + "name": "params", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_count": { + "name": "revision_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reports_user_id_unique": { + "name": "reports_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reports_user_id_user_id_fk": { + "name": "reports_user_id_user_id_fk", + "tableFrom": "reports", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_lists": { + "name": "saved_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items": { + "name": "items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profil": { + "name": "profil", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "saved_lists_user_id_unique": { + "name": "saved_lists_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "saved_lists_user_id_user_id_fk": { + "name": "saved_lists_user_id_user_id_fk", + "tableFrom": "saved_lists", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tadimlik_havuzu": { + "name": "tadimlik_havuzu", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kova_slug": { + "name": "kova_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tur": { + "name": "tur", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kategori_slug": { + "name": "kategori_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tip": { + "name": "tip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'genel'" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dilim": { + "name": "dilim", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gerekce": { + "name": "gerekce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "risk_notu": { + "name": "risk_notu", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trend_ozeti": { + "name": "trend_ozeti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "program": { + "name": "program", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ornek_sira": { + "name": "ornek_sira", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uretim_at": { + "name": "uretim_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tadimlik_anahtar": { + "name": "tadimlik_anahtar", + "columns": [ + "kova_slug", + "tur", + "kategori_slug", + "tip" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_paket": { + "name": "has_paket", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "kredi_bitti_at": { + "name": "kredi_bitti_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kredi_hatirlatma_gonderildi_at": { + "name": "kredi_hatirlatma_gonderildi_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d07b295..07bb034 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1790287503094, "tag": "0005_uneven_omega_red", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1791372024282, + "tag": "0006_little_luckman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index e2ad7c0..599feb2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@cds/city": "^1.1.0", "@libsql/client": "^0.17.4", "@shadcn/helpers": "^0.2.0", + "@shadcn/react": "0.3.1", "ai": "^7.0.127", "better-auth": "^1.6.23", "better-sqlite3": "^12.11.1", @@ -56,12 +57,10 @@ "resend": "^6.18.0", "server-only": "^0.0.1", "shiki": "^4.3.1", - "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "turkey-map-react": "^2.0.5", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6", - "vaul": "^1.1.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb9b06..987b02e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@shadcn/helpers': specifier: ^0.2.0 version: 0.2.0(ai@7.0.127(zod@4.4.3)) + '@shadcn/react': + specifier: 0.3.1 + version: 0.3.1(@types/react@19.2.17)(react@19.2.4) ai: specifier: ^7.0.127 version: 7.0.127(zod@4.4.3) @@ -95,9 +98,6 @@ importers: shiki: specifier: ^4.3.1 version: 4.3.1 - sonner: - specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -110,9 +110,6 @@ importers: use-stick-to-bottom: specifier: ^1.1.6 version: 1.1.6(react@19.2.4) - vaul: - specifier: ^1.1.2 - version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) zod: specifier: ^4.4.3 version: 4.4.3 @@ -2011,6 +2008,17 @@ packages: ai: optional: true + '@shadcn/react@0.3.1': + resolution: {integrity: sha512-2gOR0HDMtWeRsCZfNDaU0YDFdgH3zsDQ6lz67Fv/y/qjY9y+R8kJqAn6q56phqv7/zHi0wqURjntrNO9zL7vnQ==} + peerDependencies: + '@types/react': '>=19' + react: '>=19' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + '@shikijs/core@4.3.1': resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} engines: {node: '>=20'} @@ -5263,12 +5271,6 @@ packages: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonner@2.0.7: - resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5654,12 +5656,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vaul@1.1.2: - resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} @@ -7452,6 +7448,11 @@ snapshots: optionalDependencies: ai: 7.0.127(zod@4.4.3) + '@shadcn/react@0.3.1(@types/react@19.2.17)(react@19.2.4)': + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.4 + '@shikijs/core@4.3.1': dependencies: '@shikijs/primitive': 4.3.1 @@ -11230,11 +11231,6 @@ snapshots: ip-address: 10.2.0 smart-buffer: 4.2.0 - sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -11671,15 +11667,6 @@ snapshots: vary@1.1.2: {} - vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@radix-ui/react-dialog': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - verror@1.10.0: dependencies: assert-plus: 1.0.0 diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index 530c282..3c1d018 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -15,6 +15,14 @@ import { barajTablosu, type AracBaglami } from "@/lib/ai/araclar"; import { danismanAkisi } from "@/lib/ai/sohbet-akisi"; import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt"; import { modelPenceresi, onayMesajiMi } from "@/lib/ai/sohbet-gecmisi"; +import { + anketCevabiDogrula, + anketCevabiMetni, + anketModelMetni, + type RevizyonOzeti, + type SohbetMeta, +} from "@/lib/ai/sohbet-meta"; +import { MAX_REVIZYON } from "@/lib/credits"; import { profilGetir } from "@/lib/danisman-profil-db"; import { listeOzetiCikar } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; @@ -75,11 +83,20 @@ export async function POST(request: NextRequest) { if (hamBody === null || typeof hamBody !== "object") { return Response.json({ error: "Geçersiz istek." }, { status: 400 }); } - const body = hamBody as { message?: unknown; clientMessageId?: unknown }; - const mesaj = typeof body.message === "string" ? body.message.trim() : ""; + const body = hamBody as { + message?: unknown; + clientMessageId?: unknown; + /** Danışmanın soru formuna cevap (sohbet-anketi.tsx); metin istemcide de üretilir ama sunucu kendi yazar. */ + anketCevabi?: { anketId?: unknown; cevaplar?: unknown }; + }; + let mesaj = typeof body.message === "string" ? body.message.trim() : ""; const clientMessageId = typeof body.clientMessageId === "string" ? body.clientMessageId : ""; - if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) { + const anketCevabiHam = + body.anketCevabi && typeof body.anketCevabi === "object" && typeof body.anketCevabi.anketId === "string" + ? { anketId: body.anketCevabi.anketId, cevaplar: body.anketCevabi.cevaplar } + : null; + if ((!mesaj && !anketCevabiHam) || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) { return Response.json({ error: "Geçersiz mesaj." }, { status: 400 }); } @@ -101,6 +118,7 @@ export async function POST(request: NextRequest) { }), profilGetir(session.user.id), ]); + const paketli = Boolean(kullanici?.hasPaket); if (!rapor?.result) { return Response.json( { error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" }, @@ -114,11 +132,27 @@ export async function POST(request: NextRequest) { // gönderilen "evet"ler bedelsiz model çağrısı açmasın. const sonMesaj = gecmis.at(-1); const sonAsistan = sonMesaj?.role === "assistant" ? sonMesaj.content : undefined; - const bedelsiz = onayMesajiMi(mesaj, sonAsistan); + // Soru formunun cevabı da bedelsiz (7 Eki 2026): yalnız EN SON danışman + // mesajının formu için ve o formun kimliğiyle. Cevap metni sunucuda formdan + // üretilir; istemcinin yazdığı metin kullanılmaz. + const sonAnket = sonMesaj?.role === "assistant" ? sonMesaj.meta?.anket : undefined; + let kullaniciMeta: SohbetMeta | undefined; + if (anketCevabiHam) { + if (!sonAnket || sonAnket.id !== anketCevabiHam.anketId) { + return Response.json({ error: "Bu form artık geçerli değil; isteğini yazarak söyleyebilirsin." }, { status: 409 }); + } + const cevaplar = anketCevabiDogrula(sonAnket, anketCevabiHam.cevaplar); + if (!cevaplar || Object.keys(cevaplar).length === 0) { + return Response.json({ error: "Formda bir cevap seçilmedi." }, { status: 400 }); + } + mesaj = anketCevabiMetni(sonAnket, cevaplar); + kullaniciMeta = { anketCevabi: { anketId: sonAnket.id, cevaplar } }; + } + const bedelsiz = Boolean(anketCevabiHam) || onayMesajiMi(mesaj, sonAsistan); // Kredi düşümü stream'den önce — yetersizse hiç başlama const harcama = bedelsiz - ? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId }) + ? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId, meta: kullaniciMeta }) : await spendCreditForMessage({ userId: session.user.id, content: mesaj, @@ -141,7 +175,7 @@ export async function POST(request: NextRequest) { // Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi // halde danışman kilitli listeyi sözle sızdırabilir. - const kilitli = !kullanici?.hasPaket; + const kilitli = !paketli; const raporSonuc = rapor.result as RaporSonuc; const tamOzet = listeOzetiCikar( raporSonuc, @@ -162,6 +196,7 @@ export async function POST(request: NextRequest) { uyarilar: raporSonuc.uyarilar, kapsam: raporSonuc.kapsam ? { toplam: raporSonuc.kapsam.toplam, uyarlandi: raporSonuc.kapsam.uyarlandi } : undefined, }, + revizyon: { kalanHak: Math.max(0, MAX_REVIZYON - rapor.revisionCount), paketli }, }); // Araçların bağlamı: aday sırası/puan türü (arama süzgeci), profil // (profil_guncelle yazınca aynı tur içinde tazelenir) ve sihirbaz seçimleri @@ -170,6 +205,9 @@ export async function POST(request: NextRequest) { userId: session.user.id, sira: raporParams.sira, tur: raporParams.tur, + hasPaket: paketli, + mesajId: kullaniciMesajId, + kalanRevizyon: Math.max(0, MAX_REVIZYON - rapor.revisionCount), profil, oncelikler: raporParams.oncelikler ?? [], ozelDurum: raporParams.ozelDurum ?? [], @@ -229,16 +267,27 @@ export async function POST(request: NextRequest) { } // Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere - // modele bozuk sıra göndermesin - const mesajlar = modelPenceresi(gecmis, mesaj); + // modele bozuk sıra göndermesin. Kayıtlı asistan metni soru formunu + // içermez; model cevapların neye verildiğini formun dökümünden görür. + const mesajlar = modelPenceresi( + gecmis.map((m) => + m.role === "assistant" && m.meta?.anket + ? { role: m.role, content: `${m.content}\n\n${anketModelMetni(m.meta.anket)}` } + : { role: m.role, content: m.content }, + ), + mesaj, + ); const userId = session.user.id; + // Bu turda araçların bıraktığı yapısal ek (soru formu, revizyon sonucu) + const cevapMeta: SohbetMeta = {}; const cevabiKaydet = (metin: string) => appDb.insert(schema.chatMessages).values({ id: crypto.randomUUID(), userId, role: "assistant", content: metin, + meta: Object.keys(cevapMeta).length ? cevapMeta : null, createdAt: new Date(), }); @@ -248,6 +297,15 @@ export async function POST(request: NextRequest) { system, mesajlar, aracBaglami, + onAracSonucu: (ad, cikti) => { + const c = cikti as Record; + if (ad === "soru_sor" && c.anket) cevapMeta.anket = c.anket as SohbetMeta["anket"]; + if (ad === "liste_revize" && c.ok === true) { + const { not: _not, ...ozet } = c as Record; + void _not; + cevapMeta.revizyon = ozet as RevizyonOzeti; + } + }, onTamam: async (metin) => { await cevabiKaydet(metin); }, diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx index e3a8453..2edeab0 100644 --- a/src/app/listem/page.tsx +++ b/src/app/listem/page.tsx @@ -5,8 +5,8 @@ import { ListemIcerikSkeleton, } from "@/features/rapor/components/listem-icerik"; -// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort -// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan +// Liste üretimi (listeOlustur) 2 AI denemesi yapabiliyor (deneme başına 60 sn +// abort sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan // SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da // bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin // istek süresi limiti de >= 200 sn olmalı. diff --git a/src/components/ui/questionnaire.tsx b/src/components/ui/questionnaire.tsx new file mode 100644 index 0000000..0f56200 --- /dev/null +++ b/src/components/ui/questionnaire.tsx @@ -0,0 +1,329 @@ +"use client"; + +// shadcn `questionnaire` (https://ui.shadcn.com/docs/react/questionnaire, +// radix-nova kayıt öğesi; ilkel: @shadcn/react/questionnaire), sitenin diline +// çevrildi (DESIGN.md §4.6, §4.17, §4.24): `border-input / bg-muted / +// text-muted-foreground` token'ları yerine slate ölçeği ve `bg-card`; seçili +// seçenek sihirbaz çipi gibi `border-primary bg-primary text-white`; hover +// tek turuncu (§2.1); dokunma hedefi 44 px; köşe `rounded-2xl` (§4.4 sade +// kutu); düğmeler sitenin `Button`'u. Yapı ve davranış (tek tek soru, +// ilerleme, doğrulama, klavye kısayolu, atlama) ilkelden gelir. Danışman +// sohbetindeki soru formu bunu kullanır (features/rapor/components/sohbet-anketi.tsx). + +import * as React from "react"; +import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire"; +import { Check } from "lucide-react"; +import { buttonVariants, type Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Questionnaire({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireProgress({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireChoices({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +/** + * Seçenek satırı: 44 px sade kutu; seçilince sihirbaz çipi gibi mavi dolar ve + * başındaki işaret tik olur — seçili hâl renk + tik'tir (§2.2). Klavye + * kısayolu (harf) sağda küçük bir kutuda durur. + */ +function QuestionnaireChoice({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-ring/50", + "data-checked:border-primary data-checked:bg-primary data-checked:text-white data-checked:hover:border-primary data-checked:hover:bg-primary data-checked:hover:text-white", + "data-invalid:border-red-500", + "data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50", + className, + )} + {...props} + > + + + + {children} + + + + ); +} + +function QuestionnaireChoiceDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +/** Serbest cevap alanı: §4.6 metin alanı hapı (44 px, bu kutuda bir kademe kısa). */ +function QuestionnaireInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ +
+ ); +} + +function QuestionnaireError({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function QuestionnaireActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +type DugmeProps = Pick, "size" | "variant">; + +function QuestionnairePrevious({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & DugmeProps) { + return ( + + {children ?? "Geri"} + + ); +} + +function QuestionnaireSkip({ + children, + className, + size = "default", + variant = "ghost", + ...props +}: React.ComponentProps & DugmeProps) { + return ( + + {children ?? "Atla"} + + ); +} + +function QuestionnaireNext({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & DugmeProps) { + return ( + + {children ?? "İleri"} + + ); +} + +function QuestionnaireSubmit({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & DugmeProps) { + return ( + + {children ?? "Gönder"} + + ); +} + +export { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoiceDescription, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +}; diff --git a/src/features/rapor/components/arac-pilleri.tsx b/src/features/rapor/components/arac-pilleri.tsx index 4bd9434..19a44d7 100644 --- a/src/features/rapor/components/arac-pilleri.tsx +++ b/src/features/rapor/components/arac-pilleri.tsx @@ -41,8 +41,12 @@ const ARAC_ETIKETI: Record = { universite_siralamasi: { surer: "Üniversite sıralamasına bakıyorum", bitti: "Üniversite sıralamasına baktım" }, kapanan_program_ara: { surer: "Kapanan programlara bakıyorum", bitti: "Kapanan programlara baktım" }, profil_guncelle: { surer: "Tercihini kaydediyorum", bitti: "Tercihini kaydettim" }, + liste_revize: { surer: "Listeni yeniden kuruyorum", bitti: "Listeni yeniden kurdum" }, }; +/** Hap olarak çizilmeyen araçlar: soru formu balonun içinde form olarak görünür (sohbet-anketi.tsx). */ +const HAPSIZ_ARACLAR = new Set(["soru_sor"]); + const VARSAYILAN_ETIKET = { surer: "Veriye bakıyorum", bitti: "Veriye baktım" }; /** Hapın metni; hata renkle değil sözle söylenir (§2.2). */ @@ -50,7 +54,9 @@ export function aracPiliMetni(p: AracPili): string { const e = ARAC_ETIKETI[p.arac] ?? VARSAYILAN_ETIKET; const sayi = p.sayi > 1 ? ` ×${p.sayi}` : ""; if (p.durum === "calisiyor") return `${e.surer}${sayi}…`; - if (p.durum === "hata") return `${e.bitti}${sayi} (veri gelmedi)`; + if (p.durum === "hata") { + return p.arac === "liste_revize" ? `Listeni yeniden kuramadım${sayi}` : `${e.bitti}${sayi} (veri gelmedi)`; + } return `${e.bitti}${sayi}`; } @@ -65,6 +71,7 @@ export function aracPilleri(m: UIMessage): AracPili[] { for (const p of m.parts) { if (!p.type.startsWith("tool-") || !("state" in p)) continue; const arac = p.type.slice("tool-".length); + if (HAPSIZ_ARACLAR.has(arac)) continue; let t = toplam.get(arac); if (!t) { t = { calisiyor: 0, hata: 0, sayi: 0 }; diff --git a/src/features/rapor/components/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index f86a7e7..0aa55a6 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -19,6 +19,7 @@ import { SlidersHorizontal, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { BolmeTutamaci } from "@/components/ui/bolme-tutamaci"; import { RaporListesi } from "./rapor-listesi"; import { useGorunumOlayi } from "@/components/use-gorunum-olayi"; import { tercihFarki } from "@/features/rapor/tercih-farki"; @@ -31,12 +32,12 @@ import type { RaporParams, RaporSonuc } from "@/features/rapor/types/rapor"; import { SohbetClient, SOHBET_GENISLEME_GECISI, + sohbetiOdakla, type DanismanOzeti, type IlkTercihYorumu, type Mesaj, type ProfilSatiri, } from "./sohbet-client"; -import { RevizyonKutusu } from "./revizyon-kutusu"; import { profilDuzenlemeyiAc, useTercihProfili, @@ -46,6 +47,17 @@ import { ozelDurumOzeti } from "@/lib/ozel-durum"; import { PUAN_TURLERI } from "@/types/yokatlas"; import { DILIM_ETIKET } from "@/lib/risk"; +// Sohbet sütununun sürüklenebildiği aralık (DESIGN.md §4.25): sohbet 320 px'in +// altına, liste 420 px'in altına inmez; sohbet 820 px'i geçmez. +const SOHBET_EN_AZ = 320; +const SOHBET_EN_COK = 820; +const LISTE_EN_AZ = 420; +/** Izgaranın sütun aralığı (gap-6); tutamaç bu boşlukta durur. */ +const SUTUN_ARALIGI = 24; +/** Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440. */ +const kolonTabani = () => + window.matchMedia("(min-width: 1280px)").matches ? 440 : 380; + // Başlık satırındaki ikincil düğmeler: 44 px, hover tek turuncu (§4.1, §2.1) const IKINCIL_BUTON = "h-11 cursor-pointer px-4 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"; @@ -101,22 +113,20 @@ export function ListemGovde({ // yeniden akar. Telefonda (lg altı) ızgara tek sütun, yalnız boy değişir. const [sohbetGenis, setSohbetGenis] = useState(false); const izgaraRef = useRef(null); + const sohbetSutunuRef = useRef(null); const hareketAz = useReducedMotion(); - function sohbetGenisligiAyarla(genis: boolean) { - setSohbetGenis(genis); + /** Sohbet sütununu yayla hedef genişliğe götürür; `null` sınıftaki varsayılana döner. */ + function kolonaGit(hedefPx: number | null) { const el = izgaraRef.current; if (!el || !window.matchMedia("(min-width: 1024px)").matches) return; - // Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440 - const taban = window.matchMedia("(min-width: 1280px)").matches ? 440 : 380; - const hedef = genis - ? Math.max(taban, Math.round(Math.min(el.clientWidth * 0.6, 760))) - : taban; + const taban = kolonTabani(); + const hedef = hedefPx ?? taban; const simdiki = parseFloat(el.style.getPropertyValue("--sohbet-kolon")) || taban; const bitir = () => { - // Dar hâlde satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur - if (genis) el.style.setProperty("--sohbet-kolon", `${hedef}px`); - else el.style.removeProperty("--sohbet-kolon"); + // Varsayılanda satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur + if (hedefPx == null) el.style.removeProperty("--sohbet-kolon"); + else el.style.setProperty("--sohbet-kolon", `${hedef}px`); }; if (hareketAz) { bitir(); @@ -130,6 +140,17 @@ export function ListemGovde({ ).then(bitir); } + function sohbetGenisligiAyarla(genis: boolean) { + setSohbetGenis(genis); + const el = izgaraRef.current; + if (!el) return; + kolonaGit( + genis + ? Math.max(kolonTabani(), Math.round(Math.min(el.clientWidth * 0.6, 760))) + : null, + ); + } + const pinVerisi = useMemo( () => pinleriTuret( @@ -193,9 +214,14 @@ export function ListemGovde({ if (params.ozelDurum?.length) { satirlar.push(`Özel kontenjan durumun: ${ozelDurumOzeti(params.ozelDurum)}`); } + if (hasPaket) { + satirlar.push( + `Listende değişiklik isteyebilirsin: danışman gerekirse kısa bir formla sorar, sonra listeyi yeniden kurar (kalan hak: ${kalanHak})`, + ); + } satirlar.push("Bu sohbetteki son mesajların"); return satirlar; - }, [adaySira, params, hasPaket, acikSatir]); + }, [adaySira, params, hasPaket, acikSatir, kalanHak]); // Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez). // Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları @@ -212,9 +238,12 @@ export function ListemGovde({ ); return ( - // Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = SOHBET_KOLON_TABANI); - // "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla), liste - // ve harita daralan 1fr sütunda kendini yeniden akıtır. DESIGN.md §4.20. + // Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = kolonTabani); + // "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla), + // aradaki tutamaç elle sürükletir (§4.25); liste ve harita daralan 1fr + // sütunda kendini yeniden akıtır. DESIGN.md §4.20. + // Telefonda (lg altı) tek sütun: danışman en üstte, paywall kartı en altta + // (Bilal, 7 Eki 2026) — sütun kabı `contents` olur, sıra `order` ile kurulur.
- + + ) : null}
-
+
+ {/* Liste ⇄ sohbet bölmesi: sütun aralığında durur, yalnız geniş ekranda */} + + sohbetSutunuRef.current?.getBoundingClientRect().width ?? kolonTabani() + } + yaz={(px) => + izgaraRef.current?.style.setProperty("--sohbet-kolon", `${px}px`) + } + sinirlar={() => { + const en = izgaraRef.current?.clientWidth ?? 1024; + return { + enAz: SOHBET_EN_AZ, + enCok: Math.max( + SOHBET_EN_AZ, + Math.min(en - SUTUN_ARALIGI - LISTE_EN_AZ, SOHBET_EN_COK), + ), + }; + }} + onSifirla={() => kolonaGit(null)} + className="absolute inset-y-0 -left-6 hidden lg:block" + /> {hasPaket ? ( - - ) : ( -
- {/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki - "Danışmana sor" butonu) doğrudan danışmana düşsün. */} +
+
+ ) : ( +
+ {/* Sohbet paket kartının üstünde: #sohbet çapası doğrudan + danışmana düşsün. */} +
+ +

diff --git a/src/features/rapor/components/revizyon-kutusu.tsx b/src/features/rapor/components/revizyon-kutusu.tsx deleted file mode 100644 index 2f7050e..0000000 --- a/src/features/rapor/components/revizyon-kutusu.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"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 { useRef, useState } from "react"; -import { useRouter } from "next/navigation"; -import { RefreshCcw } from "lucide-react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { listeRevize } from "@/features/rapor/rapor-actions"; -import { olay } from "@/lib/analitik"; - -export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) { - const [feedback, setFeedback] = useState(""); - const [pending, setPending] = useState(false); - const router = useRouter(); - // Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry - // çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı - // ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts). - const requestIdRef = useRef(null); - - if (kalanHak <= 0) { - return ( -
-

- Revizyon hakların doldu. Danışman sohbetinden listen hakkında soru - sormaya devam edebilirsin. -

-
- ); - } - - async function revizeEt() { - setPending(true); - try { - if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); - const sonuc = await listeRevize({ - feedback: feedback.trim(), - requestId: requestIdRef.current, - }); - requestIdRef.current = null; - if (sonuc.ok) { - olay("liste_revize", { sayfa: "listem" }); - 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 ( -
-

- Listede değişiklik mi istiyorsun? -

-

- Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun - (3 kredi). -

-