Compare commits

...

2 Commits

Author SHA1 Message Date
bilalgursen
4d30dd1305 feat(sohbet): liste revizyonu sohbete taşındı — soru formu ve yapısal kural
Eski revizyon kutusu kaldırıldı: serbest metin modele gidiyor, sıra ve havuz
koddan belirlendiği için "ilk 5 X ilinden olsun" gibi isteklerin karşılığı
yoktu. Danışman artık belirsiz isteği soru formuyla (soru_sor → shadcn
Questionnaire, proje diline çevrildi) netleştirir, sonra liste_revize ile
yapısal kural verir: il/alan/tip farkı, "ilk N" ölçütü, çıkar/ekle,
iskelet (lib/revizyon-kurali.ts). Kurallar havuz, seçim ve sırada koddan
uygulanır; "ilk N" ölçütünün ili/alanı süzgece kendiliğinden eklenir.

- chat_messages.meta sütunu (göç 0006): form, form cevabı ve revizyon sonucu
  kayıttan geri kurulur; form cevabı kredi harcamaz.
- Akış korumaları: form açılınca başka araç yok, revizyonda zaman aşımı uzar,
  "yeniden kurdum" beyanı araçsızsa düzeltme notu.
- /listem "Revize et" sohbeti odaklar; başarılı revizyonda liste yenilenir.
- /dev/sohbet senaryosu, DESIGN.md §4.20 notu ve §4.33.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00
bilalgursen
3c995402c6 feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00
67 changed files with 8866 additions and 1111 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ Durum: `aday` → `hazır` (kabul kriteri yazıldı) → `onaylı` (CEO/Bilal)
| 34 | "Yurtdışı imkânı" çipini geri getirecek veri: program/üniversite düzeyinde Erasmus+ ve değişim verisi | Çip 6 Eki 2026'da kalktı; 25 Eyl araştırması açık kaynak bulamadı. Aday: Ulusal Ajans Erasmus+ KA131 hibe/öğrenci sayıları (üniversite düzeyi, yıllık yayın), üniversitelerin değişim anlaşma listeleri | Üniversite düzeyi yeter (program düzeyi yok); kaynak + dönem yazılır; `uyum-puani` boyutu; çip geri gelir | M | aday | KARARLAR 2026-10-06 |
| 35 | Kontenjan türü başına taban sırası (okul birincisi, şehit-gazi yakını, depremzede, 34+ kadın) | Çekmece ve danışman "o kontenjanın taban sırası elimizde yok" diyor; ÖSYM yerleştirme sonuç tablolarında (Tablo-4 PDF) kontenjan türü başına en küçük puan/sıra var, API vermiyor | `scripts/detay.ts --kirilim` ya da ayrı betikle tür başına sıra DB'ye alınır (`obkSira<yıl>` vb.); `program_detay` ve çekmece gösterir; dilim hesabına **girmez** (adayın hangi kontenjandan yerleşeceği belli değil) | M | aday | KARARLAR 2026-10-06 |
| 36 | `scripts/refresh.ts` yerleşen toplamına `y34kY` dahil değil; `veri-kalite` 6.096/19.857 satırda tür toplamı ≠ `yerlesen2025` | 6 Eki 2026 `veri-kalite` yeni kontrolü ("yerlesen = türlerin yerleşen toplamı") UYARI veriyor; fark yalnız 34+ kadın yerleşenlerden büyük görünüyor, kaynak API'nin `gkY` tanımı doğrulanmalı | `refresh.ts:213` toplamı ve API alan tanımları ham çekimle karşılaştırılır; uyumsuzluk ≤ %2'ye iner ya da kontrol eşiği gerekçesiyle güncellenir; DB yeniden üretilmez, yalnız bir sonraki yıl verisinde uygulanır | S | aday | `scripts/veri-kalite.ts` 6b |
| 37 | Üst çubuk hapı Arc "Booking pill" gibi adım adım biçim değiştirsin (puan türü → sıralama → bilet → kaydedildi) | 7 Eki 2026'da uygulandı, tarayıcıda doğrulandı, Bilal'in kararıyla geri alındı; ölçülen davranış, tasarım kararları ve tam kod `docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md`'de | Belgedeki iki dosya + yama geri uygulanır; tsc/lint/denetim temiz; profilsiz yol modalı Adım 1/3'te açar, profilli yol sıra+türü yerinde yazar | S (hazır kod) | aday (Bilal kararı) | `docs/arastirma/2026-10-07-booking-pill-sekil-hapi.md` |
Bilal kararı bekleyen, iş olmayanlar: hero başlığı (D1), "üniversitelerden ödeme almayız" beyanı (D8), vakıf üniversitelerinin adıyla doluluk yayını, THE/QS bantları (varsayılan: gösterme), `/hakkinda` + satıcı kimliği (sezon öncesi yeniden), net/OBP "YÖK Atlas kaldırdı, bizde duruyor" cümlesi (önce iki doğrulama + yıl aralığı 2023–2025), dört gece dalının merge'ü (#8a, #9, #14, #15), Rybbit panelinde ekip trafiğini dışlama + Web Vitals.

View File

@@ -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]` + `<XIcon className="size-4" aria-hidden />` + `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: `<Card className="relative h-full overflow-hidden border-slate-200/70 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">` + sağ üstte `<PixelCorner className="absolute right-4 top-4" />`. (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"`.
`<AltSayfaIcerik>` + §4.3 çarpısı + `<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">`.
İç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": "<px>" })` 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/<il>`, `/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
`<canvas>`, 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.

View File

@@ -0,0 +1 @@
ALTER TABLE `chat_messages` ADD `meta` text;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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": {

51
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -103,7 +103,7 @@ const KURALLAR: Kural[] = [
ad: "modal-rozet",
seviye: "hata",
mesaj: "Çekmece/modal içinde SectionEyebrow yok; başlık + tek cümle bağlam (DESIGN.md §4.10)",
bul: blokIcinde(["DrawerContent", "DialogContent"], /<SectionEyebrow\b/),
bul: blokIcinde(["AltSayfaIcerik", "DialogContent"], /<SectionEyebrow\b/),
},
{
ad: "cta-yildiz",

View File

@@ -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<string, unknown>;
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<string, unknown>;
void _not;
cevapMeta.revizyon = ozet as RevizyonOzeti;
}
},
onTamam: async (metin) => {
await cevabiKaydet(metin);
},

View File

@@ -449,30 +449,38 @@
}
}
/* Bildirim (toast, sonner) — DESIGN.md §4.16. Başlık ve açıklama slate
tonlarında okunur; aksiyon düğmesi üst çubuktaki koyu hap dilinde.
".toaster" öneki şart: sonner kendi stilini çalışma anında enjekte eder,
aynı özgüllükteki kural onun altında kalır. */
.toaster [data-sonner-toast][data-styled="true"] {
border-radius: 1rem;
border-color: var(--color-slate-200);
/* Veri akışı (veri-akisi.tsx, DESIGN.md §4.28): kare, çizgi boyunca karodan
merkeze akar. Uç noktalar satır içi --x1/--y1/--x2/--y2 ile gelir (SVG
içinde px = viewBox birimi). Yalnız ekrandayken oynar. */
@keyframes kt-akis {
0% {
translate: var(--x1) var(--y1);
opacity: 0;
}
12%,
82% {
opacity: 1;
}
100% {
translate: var(--x2) var(--y2);
opacity: 0;
}
}
.toaster [data-sonner-toast][data-styled="true"] [data-title] {
font-weight: 600;
color: var(--color-slate-900);
.kt-akis-kare {
translate: var(--x1) var(--y1);
opacity: 0;
animation: kt-akis 2.4s cubic-bezier(0.45, 0, 0.55, 1) infinite;
animation-play-state: paused;
}
.toaster [data-sonner-toast][data-styled="true"] [data-description] {
color: var(--color-slate-600);
[data-oynuyor] .kt-akis-kare {
animation-play-state: running;
}
.toaster [data-sonner-toast][data-styled="true"] [data-button] {
height: 2.25rem;
padding-inline: 0.875rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-slate-50);
background: var(--color-slate-900);
}
.toaster [data-sonner-toast][data-styled="true"] [data-button]:hover {
background: var(--color-slate-700);
@media (prefers-reduced-motion: reduce) {
.kt-akis-kare {
animation: none;
}
}

View File

@@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/sonner";
import { BildirimYigini } from "@/components/ui/bildirim";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
@@ -129,7 +129,7 @@ export default function RootLayout({
<TercihProfiliKapisiLazy />
{/* İlk ziyarette bir kez: çerez bilgilendirmesi (onay penceresi değil) */}
<CerezBilgi />
<Toaster position="top-center" />
<BildirimYigini />
{/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */}
<ConsoleAsr />
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}

View File

@@ -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ı.

View File

@@ -1,16 +1,12 @@
import {
ArrowRight,
ArrowUpRight,
BarChart3,
Coins,
Compass,
FileText,
ListChecks,
MessageCircleQuestion,
RefreshCcw,
ShieldCheck,
TrendingUp,
Wallet,
} from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -22,9 +18,13 @@ import { Parallax } from "@/components/parallax";
import { Reveal } from "@/features/pazarlama/components/reveal";
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
import { SuzulenHaplar } from "@/features/pazarlama/components/suzulen-haplar";
import { VeriAkisi } from "@/features/pazarlama/components/veri-akisi";
import { katalogHaplari } from "@/features/pazarlama/landing-verisi";
import {
PixelCorner,
PixelDivider,
PixelRakam,
PixelSahne,
SectionEyebrow,
} from "@/components/pixel-decor";
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
@@ -41,12 +41,6 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
@@ -58,11 +52,9 @@ import {
import type { Metadata } from "next";
import {
JsonLd,
KATALOG_VERI_TARIHI,
faqPageJsonLd,
softwareApplicationJsonLd,
} from "@/lib/seo";
import { KATALOG_TARIH_METNI } from "@/features/katalog/components/veri-kaynagi";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
@@ -76,20 +68,55 @@ const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
// Sorun kartlarının piksel sahneleri (DESIGN.md §4.31): hero haritasıyla aynı
// ızgara dili — `#` doku, `-` silik, `o` turuncu vurgu. Her biri kartın
// söylediğini çizer: binlerce programın içinde tam 24 seçim; her yıl başka
// boyda çubuklar ve yalnız sonuncusu işaretli; basamak basamak tırmanan fiyat.
const SAHNE_IHTIMAL = [
"o#o#######o##o##o#######",
"####o###o#########o###o#",
"######o####o###o########",
"#o##o###################",
"######o##o####o####o##o#",
"#o##########o####o######",
".###..o#......####.#..o.",
"..#...###...##......#...",
];
const SAHNE_YILLAR = [
"........................",
".................##.....",
".........##......##.....",
".##......##......##.....",
".##......##..##..##..oo.",
".##..##..##..##..##..##.",
".##..##..##..##..##..##.",
".##..##..##..##..##..##.",
];
const SAHNE_FIYAT = [
".....................ooo",
"..................###---",
"...............###------",
"............###---------",
".........###------------",
"......###---------------",
"...###------------------",
"###---------------------",
];
const problems = [
{
icon: ListChecks,
sahne: SAHNE_IHTIMAL,
title: "24 tercih, binlerce ihtimal",
description: `${PROGRAM_SAYISI_CUMLE} lisans ve önlisans programı arasından sana uyan 24 tanesini doğru sırayla dizmek, tablolarla boğuşarak günler alıyor.`,
},
{
icon: TrendingUp,
sahne: SAHNE_YILLAR,
title: "Geçen yılın puanı yetmiyor",
description:
"Taban sıralamaları her yıl kayıyor; tek yıla bakmak riski gizler.",
},
{
icon: Wallet,
sahne: SAHNE_FIYAT,
title: "Danışman lüks haline geldi",
description:
"Armut'ta tercih danışmanlığı ilanları ortalama 1.000–3.000 TL (Eylül 2026). Ücretsiz robotların çoğu ise yalnızca liste filtreliyor; listeyi kurup gerekçesini yazmıyor.",
@@ -98,26 +125,34 @@ const problems = [
const steps = [
{
icon: Compass,
step: "1",
step: 1,
title: "Sıralamanı ve hedeflerini anlat",
description:
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.",
},
{
icon: BarChart3,
step: "2",
step: 2,
title: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
description:
"YÖK Atlas'ın taban sıralaması trendleri, kontenjan değişimleri ve doluluk oranları sıralamana göre elensin; yapay zekâ yalnızca bu havuzdan seçer, dışına çıkamaz.",
},
{
icon: ShieldCheck,
step: "3",
step: 3,
title: "Dengeli 24'lük listeni al",
description:
"Hayal, dengeli ve güvenli dilimlerine dağıtılmış, her satırı gerekçeli tercih listeni indir; aklına takılanı listen üzerinden yapay zekâya sor.",
},
] as const;
// "Nasıl çalışır"da akışın yanında duran üç sayı (DESIGN.md §4.28).
const veriSayilari = [
{ value: PROGRAM_SAYISI_METNI, label: "Taranan lisans ve önlisans programı" },
{
value: String(VERI_YIL_SAYISI),
birim: "yıl",
label: "Taban sıralaması trend verisi",
},
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" },
];
const faqs = [
@@ -217,6 +252,10 @@ const paketOzellikleri = [
] as const;
export default function Home() {
// Katalog hapları gerçek veriden (landing-verisi.ts); veri okunamazsa
// bölüm çizilmez.
const haplar = katalogHaplari();
return (
<div
id="top"
@@ -257,27 +296,6 @@ export default function Home() {
>
<HeroForm />
</div>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-card px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
<span>
<strong className="font-semibold text-slate-900">
Veri: YÖK Atlas
</strong>{" "}
· son güncelleme{" "}
<time dateTime={KATALOG_VERI_TARIHI}>
{KATALOG_TARIH_METNI}
</time>
</span>
</div>
{/* Markayı tek cümlede tanımlayan görünür metin: sitedeki SSS ve
/kosullar ifadelerinin toplamı, yeni vaat yok. Meta/OG'de zaten
var; yapay zekâ cevap motorları gövde metnini önceliklendirdiği
için burada da durur (docs/arastirma/2026-09-24-geo-seo). */}
<p className="mt-5 max-w-2xl text-sm leading-6 text-slate-600">
KolayTercih, YÖK Atlas&apos;ın resmî yerleştirme verisiyle
sıralamana uygun 24 tercihlik dengeli liste kuran, yapay zekâ
destekli bir karar destek aracıdır. ÖSYM ya da YÖK ile bağlantılı
değildir; yerleşme garantisi vermez.
</p>
</Parallax>
</section>
@@ -303,23 +321,24 @@ export default function Home() {
</p>
</Reveal>
</Parallax>
{/* Sahne kartı (DESIGN.md §4.31): üstte hero'nun piksel diliyle
kartın söylediğini çizen sahne, altında başlık ve metin. */}
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
{problems.map((p, i) => (
<Reveal key={p.title} delay={i * 0.08}>
<Card className="relative h-full overflow-hidden border-slate-200/70 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<PixelCorner className="absolute right-4 top-4" />
<CardHeader>
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10">
<p.icon className="size-5 text-primary" aria-hidden />
</div>
<CardTitle className="font-heading text-xl">
<Reveal key={p.title} delay={i * 0.08} className="h-full">
<div className="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">
<div className="border-b border-slate-100 bg-slate-50 px-6 py-8">
<PixelSahne izgara={p.sahne} />
</div>
<div className="p-6">
<h3 className="font-heading text-xl font-bold text-balance">
{p.title}
</CardTitle>
<CardDescription className="text-base leading-relaxed">
</h3>
<p className="mt-2 text-base leading-relaxed text-slate-600">
{p.description}
</CardDescription>
</CardHeader>
</Card>
</p>
</div>
</div>
</Reveal>
))}
</Parallax>
@@ -361,77 +380,82 @@ export default function Home() {
</p>
</Reveal>
</Parallax>
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
{steps.map((s, i) => (
<Reveal key={s.step} delay={i * 0.08} className="h-full">
<div className="relative h-full rounded-2xl border border-slate-200/70 bg-card p-8 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<span className="absolute -top-4 left-8 flex size-8 items-center justify-center rounded-full bg-orange-500 font-heading text-sm font-bold text-white">
{s.step}
</span>
<PixelCorner className="absolute right-4 top-4" />
<s.icon className="size-6 text-primary" aria-hidden />
<h3 className="mt-4 font-heading text-xl font-semibold">
{s.title}
</h3>
<p className="mt-2 leading-relaxed text-slate-600">
{s.description}
</p>
</div>
</Reveal>
))}
{/* Adımlar kartsız (DESIGN.md §4.32): numara hero'nun piksel
diliyle iri rakam, md'den itibaren rakamlar düz çizgiyle
birbirine bağlanır. */}
<Parallax strength={7} className="mt-12">
<ol className="grid gap-12 md:grid-cols-3 md:gap-8">
{steps.map((s, i) => (
<li key={s.step}>
<Reveal
delay={i * 0.08}
className="relative flex flex-col items-center text-center"
>
<PixelRakam rakam={s.step} className="h-24 w-auto" />
{i < steps.length - 1 ? (
<span
aria-hidden
className="absolute top-12 left-[calc(50%+4.5rem)] hidden h-0.5 w-[calc(100%-7rem)] -translate-y-1/2 rounded-full bg-slate-200 md:block"
/>
) : null}
<h3 className="mt-6 font-heading text-xl font-bold text-balance">
<span className="sr-only">{s.step}. adım: </span>
{s.title}
</h3>
<p className="mt-2 max-w-sm text-base leading-relaxed text-slate-600">
{s.description}
</p>
</Reveal>
</li>
))}
</ol>
</Parallax>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
<Parallax strength={10} className="mx-auto mt-12 w-full max-w-3xl">
{/* Veri akışı + veriye dayalı güven + dürüstlük notu (DESIGN.md
§4.28). Üçü de kartsız, sayfa zemininde açıkta durur: geniş
ekranda solda dürüstlük notu (sola yaslı), ortada animasyon,
sağda (YÖK Atlas girdilerinin yanında) üç sayı (sağa yaslı).
lg altında hepsi ortalı; sıra: animasyon → sayılar → not. */}
<Parallax strength={10} className="mt-20">
<Reveal
delay={0.1}
className="overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm"
className="grid items-center gap-12 lg:grid-cols-[minmax(0,1fr)_22rem_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[minmax(0,1fr)_24rem_minmax(0,1fr)] xl:gap-12"
>
<dl className="grid grid-cols-1 divide-y divide-slate-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{[
{
value: PROGRAM_SAYISI_METNI,
label: "Taranan lisans ve önlisans programı",
},
{
value: `${VERI_YIL_SAYISI} yıl`,
label: "Taban sıralaması trend verisi",
},
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" },
].map((stat) => (
<VeriAkisi className="lg:order-2" />
<dl className="mx-auto grid w-full max-w-3xl grid-cols-1 gap-8 text-center sm:grid-cols-3 lg:order-3 lg:mx-0 lg:grid-cols-1 lg:gap-10 lg:text-right">
{veriSayilari.map((stat) => (
// Sayı kahramandır (DESIGN.md §1.1): iri sayı üstte, etiket
// altında; dt anlam için DOM'da önce, görsel sıra order ile.
<div
key={stat.label}
className="flex flex-col p-6 text-center"
>
<div key={stat.label} className="flex flex-col">
<dt className="order-2 mt-2 text-sm font-semibold text-slate-500">
{stat.label}
</dt>
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary">
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary sm:text-5xl">
{stat.value}
{stat.birim ? (
<span className="ml-1.5 font-sans text-base font-semibold tracking-normal text-slate-400">
{stat.birim}
</span>
) : null}
</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-sm font-semibold text-primary">
Dürüstlük notu
</p>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl">
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 className="mx-auto flex w-full max-w-xl flex-col items-center text-center lg:order-1 lg:mx-0 lg:max-w-xs lg:items-start lg:text-left">
<SectionEyebrow>Dürüstlük notu</SectionEyebrow>
<h3 className="mt-4 font-heading text-2xl font-bold text-balance text-slate-900">
Yerleşme garantisi vermiyoruz —{" "}
<span className="font-bricolage font-bold tracking-tighter text-primary italic">
çünkü kimse veremez.
</span>
</h3>
<p className="mt-3 text-base leading-relaxed 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>
</Reveal>
</Parallax>
@@ -668,6 +692,47 @@ export default function Home() {
</div>
</section>
{/* Katalog: süzülen haplar (DESIGN.md §4.30) */}
{haplar.length >= 6 ? (
<>
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={41} />
</Parallax>
</div>
<section id="katalog" className="scroll-mt-24 py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
<SectionEyebrow>Katalog ücretsiz</SectionEyebrow>
<h2 className="font-heading text-3xl font-bold text-balance sm:text-4xl">
Önce bir bakmak ister misin?
</h2>
<p className="max-w-xl text-lg leading-relaxed text-slate-600">
Taban sıralamaları ve kontenjanlar herkese açık. Üye
olmadan bölüm bölüm, şehir şehir gez.
</p>
</Reveal>
</Parallax>
<SuzulenHaplar haplar={haplar} className="mt-6" />
<div className="mt-4 flex justify-center">
<Button
asChild
size="lg"
variant="outline"
className="cursor-pointer px-6 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
>
<Link href="/bolumler">
Tüm bölümleri gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
</div>
</section>
</>
) : null}
{/* Fiyat → SSS geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>

View File

@@ -10,7 +10,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Wrench, X } from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { authClient } from "@/lib/auth-client";
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
import {

View File

@@ -470,6 +470,111 @@ export function PixelSira({
);
}
/**
* Piksel sahne (DESIGN.md §4.31): hero'daki dot-matrix Türkiye'nin dilini
* küçük resimlere taşır. Harita gibi satır satır bir ızgaradan çizilir:
* `#` doku pikseli, `-` silik piksel, `o` turuncu vurgu (hero'daki şehirler
* gibi nabız atar), başka her karakter boş. Doku pikselleri imleç ısı
* fırçasına katılır. Resim kartın söylediğini gösterir (süs değil); anlamı
* yanındaki başlık taşır, bu yüzden aria-hidden.
*/
export function PixelSahne({
izgara,
ton = "doku",
yaricap = 26,
className,
}: {
izgara: readonly string[];
/** "doku": hero haritasının silik mavisi. "dolgun": aynı doku koyu
* tonda (0,64–1) — rakam gibi okunması gereken şekiller için. */
ton?: "doku" | "dolgun";
/** Isı fırçasının yarıçapı (viewBox birimi; hücre 10) */
yaricap?: number;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: yaricap });
const cols = Math.max(...izgara.map((satir) => satir.length));
const pixels = useMemo(() => {
const opaklik = (x: number, y: number) =>
ton === "dolgun" ? 0.4 + opacityFor(x, y) * 2 : opacityFor(x, y);
const sonuc: ReactNode[] = [];
let vurgu = 0;
izgara.forEach((satir, y) => {
for (let x = 0; x < satir.length; x++) {
const c = satir[x];
if (c === "o") {
sonuc.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent: true,
accentDelay: (vurgu++ % 8) * 0.45,
}),
);
} else if (c === "#" || c === "-") {
const op =
Math.round(opaklik(x, y) * (c === "-" ? 0.5 : 1) * 100) / 100;
sonuc.push(
<rect
key={`${x}-${y}`}
x={x * CELL + INSET}
y={y * CELL + INSET}
width={DOT}
height={DOT}
rx={2}
data-px=""
data-cx={x * CELL + CELL / 2}
data-cy={y * CELL + CELL / 2}
data-op={op}
fill="currentColor"
fillOpacity={op}
/>,
);
}
}
});
return sonuc;
}, [izgara, ton]);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${cols * CELL} ${izgara.length * CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-auto w-full text-primary", className)}
>
{pixels}
</svg>
);
}
// 5×7 dot-matrix rakamlar. Yeni bir rakam gerekirse ızgarası buraya eklenir.
const RAKAMLAR = {
1: ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."],
2: [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"],
3: [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."],
} as const;
/** Hero'nun piksel diliyle yazılmış iri rakam (ana sayfa adım numaraları). */
export function PixelRakam({
rakam,
className,
}: {
rakam: keyof typeof RAKAMLAR;
className?: string;
}) {
return (
<PixelSahne
izgara={RAKAMLAR[rakam]}
ton="dolgun"
yaricap={22}
className={className}
/>
);
}
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({
children,

View File

@@ -0,0 +1,686 @@
"use client";
// Alt sayfa (bottom sheet) — DESIGN.md §4.10. Ayrıntı her zaman alttan
// açılan bu sayfadadır. Sayfa parmağı birebir izler: aşağı çekince kapanır,
// en üst durağın ötesine çekince lastik gibi direnir, bırakınca bırakma
// hızını taşıyarak en yakın durağa oturur; kapanırken yakalanıp geri
// açılabilir. Arkadaki perde zamanlayıcıyla değil sayfanın konumuyla koyulaşır.
//
// Kaynak: Arc UI "Bottom sheet" (uiarc.dev, 7 Eki 2026); eski vaul çekmecesinin
// yerini aldı. Bizdeki farklar:
// - Boy içeriğe göredir (Arc'ta sabit); `duraklar` ekran yüksekliğinin oranıdır
// ve içerik daha kısaysa içeriğin boyunda kalır. Varsayılan tek durak (0,92).
// - Parçalı kullanım: başlık, çarpı ve gövde çağıranındır (§4.10 ortalı başlık,
// §4.3 çarpı). Kaydırılan alan `AltSayfaGovde` olmalıdır — sayfa onun
// kaydırmasıyla el sıkışır (en üstteyken aşağı çekiş sayfayı indirir).
// - Yaylar sekmesiz (§6); hareketi azalt'ta kayma yok, yalnız opaklık.
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ComponentProps,
type FocusEvent,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type WheelEvent,
} from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
usePresence,
useReducedMotion,
useTransform,
} from "motion/react";
import { EGRI, SURE, YAY, birakmaHizi, lastik, lastikTersi } from "@/lib/hareket";
import { cn } from "@/lib/utils";
type Durak = number | "kapali";
type Surukleme = {
basY: number;
koken: number;
baslangic: number;
oynadi: boolean;
ornekler: { t: number; v: number }[];
};
/** Kapalıyken gölge de görünmesin diye kenarın altında bırakılan pay (px). */
const KAPALI_PAYI = 40;
/** Bırakılan sayfanın, hızıyla kaç saniye daha süzüleceği varsayılır. */
const SUZULME = 0.2;
/** Bundan hızlı bırakış (px/sn) yönünde en az bir durak ilerler. */
const FISKE = 320;
/** En üst durağın ötesine çekişin yaklaşıp geçemediği sınır (px). */
const ESNEME = 120;
/** En alçak durakta perdenin tam koyuluğa oranı. */
const ALT_KOYULUK = 0.78;
/** Ayrılış gelişten kısadır; fiske aynı yayda kendi hızını taşır. */
const AYRILMA = { ...YAY.akici, visualDuration: 0.3 };
const SOLMA = { duration: SURE.hizli, ease: EGRI.standart };
const sinirla = (v: number, alt: number, ust: number) => Math.min(ust, Math.max(alt, v));
const ekranBoyu = () => (typeof window === "undefined" ? 800 : window.innerHeight);
const KokBaglami = createContext<{ acik: boolean; kapat: () => void } | null>(null);
type Tutamak = {
onPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => void;
onPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => void;
onPointerUp: (e: ReactPointerEvent<HTMLDivElement>) => void;
onPointerCancel: (e: ReactPointerEvent<HTMLDivElement>) => void;
};
const SayfaBaglami = createContext<{
govdeBagla: (el: HTMLDivElement | null) => void;
govdeTekerlek: (e: WheelEvent<HTMLDivElement>) => void;
govdeOdak: (e: FocusEvent<HTMLDivElement>) => void;
tutamak: Tutamak;
} | null>(null);
function AltSayfa({
open: disAcik,
defaultOpen = false,
onOpenChange,
children,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (acik: boolean) => void;
children: React.ReactNode;
}) {
const [icAcik, setIcAcik] = useState(defaultOpen);
const acik = disAcik ?? icAcik;
const setAcik = useCallback(
(sonraki: boolean) => {
if (disAcik === undefined) setIcAcik(sonraki);
onOpenChange?.(sonraki);
},
[disAcik, onOpenChange],
);
const baglam = useMemo(() => ({ acik, kapat: () => setAcik(false) }), [acik, setAcik]);
return (
<DialogPrimitive.Root open={acik} onOpenChange={setAcik}>
<KokBaglami.Provider value={baglam}>{children}</KokBaglami.Provider>
</DialogPrimitive.Root>
);
}
function AltSayfaTetik(props: ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="alt-sayfa-tetik" {...props} />;
}
function AltSayfaKapat(props: ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="alt-sayfa-kapat" {...props} />;
}
type IcerikProps = Omit<ComponentProps<typeof DialogPrimitive.Content>, "asChild" | "forceMount"> & {
/** Dinlenme boyları, ekran yüksekliğinin oranı olarak. En büyüğü sayfanın tavanıdır. */
duraklar?: number[];
/** Sıralı durakların hangisinde açılacağı; verilmezse en üstteki. */
ilkDurak?: number;
/** Açılış yayı yerine oturunca `true` ile çağrılır. */
onOturdu?: (acik: boolean) => void;
};
function AltSayfaIcerik(props: IcerikProps) {
const kok = useContext(KokBaglami);
if (!kok) throw new Error("AltSayfaIcerik, AltSayfa içinde kullanılmalı.");
return (
// Sayfa ayrılırken monte kalır: kapanış yarı yolda yakalanıp geri alınabilir.
<AnimatePresence>
{kok.acik ? (
<DialogPrimitive.Portal key="alt-sayfa" forceMount>
<Sayfa {...props} kapat={kok.kapat} />
</DialogPrimitive.Portal>
) : null}
</AnimatePresence>
);
}
function Sayfa({
className,
children,
duraklar = [0.92],
ilkDurak,
onOturdu,
kapat,
onPointerDownOutside,
onInteractOutside,
onKeyDown,
...props
}: IcerikProps & { kapat: () => void }) {
const [burada, kaldirilabilir] = usePresence();
const azalt = useReducedMotion() ?? false;
const durakAnahtari = duraklar.join(",");
const kesirler = useMemo(
() =>
durakAnahtari
.split(",")
.map(Number)
.filter((d) => d > 0)
.sort((a, b) => a - b),
[durakAnahtari],
);
const ust = kesirler.length - 1;
const cokDurakli = kesirler.length > 1;
const [durak, setDurak] = useState(() => sinirla(Math.round(ilkDurak ?? ust), 0, ust));
const [duyuru, setDuyuru] = useState("");
const sayfaRef = useRef<HTMLDivElement>(null);
const govdeRef = useRef<HTMLDivElement | null>(null);
/** Sayfanın en üst durağının altındaki kayması (px). İlk kare parlamasın diye çok aşağıdan başlar. */
const y = useMotionValue(4000);
const boy = useMotionValue(0);
const varlik = useMotionValue(1);
const durakRef = useRef(durak);
const hedef = useRef<Durak>(durak);
const surukleme = useRef<Surukleme | null>(null);
const varinca = useRef<(() => void) | undefined>(undefined);
const tiklamayiYut = useRef(false);
const kuruldu = useRef(false);
const oturduRef = useRef(onOturdu);
/** Sayfanın en son ne zaman girmeye/çıkmaya başladığı: ayrılırken başlayan basış, geri açılan sayfayı kapatmasın. */
const varlikDegisimi = useRef({ burada, an: 0 });
useLayoutEffect(() => {
oturduRef.current = onOturdu;
}, [onOturdu]);
useLayoutEffect(() => {
varlikDegisimi.current = { burada, an: performance.now() };
}, [burada]);
const durakPx = useCallback(
(i: number) => Math.min(kesirler[i] * ekranBoyu(), boy.get()),
[boy, kesirler],
);
const ofset = useCallback(
(d: Durak) => (d === "kapali" ? boy.get() + KAPALI_PAYI : boy.get() - durakPx(d)),
[boy, durakPx],
);
/** Sayfa tam açık değilken gövde kaydırmaz; çekiş sayfayı taşır (bkz. AltSayfaGovde). */
const kisikligiIsaretle = useCallback(() => {
sayfaRef.current?.toggleAttribute("data-kisik", ofset(durakRef.current) > 0.5);
}, [ofset]);
const tamAcik = useCallback(() => ofset(durakRef.current) <= 0.5, [ofset]);
// Perde sayfanın konumunun fonksiyonudur: sürüklemeyle koyulaşır ve açılır.
const perde = useTransform([y, boy, varlik], ([kayma, tam, gorunen]: number[]) => {
if (!tam) return 0;
const alt = tam - Math.min(kesirler[0] * ekranBoyu(), tam);
const kapali = tam + KAPALI_PAYI;
const taban = alt > 0 ? ALT_KOYULUK : 1;
const koyuluk =
kayma <= alt
? 1 - (1 - taban) * (alt ? kayma / alt : 0)
: taban * (1 - (kayma - alt) / (kapali - alt));
return sinirla(koyuluk, 0, 1) * gorunen;
});
// Her hareket tek motion değerini bulunduğu yerden yeniden hedefler.
const git = useCallback(
(d: Durak, hiz?: number, sonra?: () => void) => {
hedef.current = d;
varinca.current = sonra;
const nokta = ofset(d);
const bitti = () => {
const cagri = varinca.current;
varinca.current = undefined;
cagri?.();
};
if (azalt) {
y.jump(nokta);
bitti();
return;
}
animate(y, nokta, {
...(d === "kapali" ? AYRILMA : YAY.akici),
...(hiz === undefined ? {} : { velocity: hiz }),
onComplete: bitti,
});
},
[azalt, ofset, y],
);
const dinlen = useCallback(
(d: number, hiz?: number) => {
if (d !== durakRef.current) {
durakRef.current = d;
setDurak(d);
if (cokDurakli) setDuyuru(d === ust ? "Sayfa genişledi" : "Sayfa küçüldü");
}
kisikligiIsaretle();
// Küçülürken içerik başa döner: kısık hâl hep en üstü gösterir.
const govde = govdeRef.current;
if (!tamAcik() && govde && govde.scrollTop > 0) {
govde.scrollTo({ top: 0, behavior: azalt ? "auto" : "smooth" });
}
git(d, hiz);
},
[azalt, cokDurakli, git, kisikligiIsaretle, tamAcik, ust],
);
// Giriş, çıkış ve çıkarken yeniden açılma aynı motion değerini yeniden hedefler.
useLayoutEffect(() => {
const sayfa = sayfaRef.current;
if (!sayfa) return;
if (!kuruldu.current) {
kuruldu.current = true;
boy.set(sayfa.offsetHeight);
y.jump(ofset("kapali"));
if (azalt) {
y.jump(ofset(durakRef.current));
varlik.jump(0);
}
kisikligiIsaretle();
}
const oturdu = () => oturduRef.current?.(true);
if (burada) {
if (azalt) {
y.jump(ofset(durakRef.current));
animate(varlik, 1, { ...SOLMA, onComplete: oturdu });
return;
}
varlik.jump(1);
git(durakRef.current, undefined, oturdu);
return;
}
if (azalt) {
animate(varlik, 0, { ...SOLMA, onComplete: kaldirilabilir });
return;
}
// Fiske çıkışı kendi hızıyla başlatmıştır; yalnız kaldırma onu bekler.
if (hedef.current === "kapali" && y.isAnimating()) varinca.current = kaldirilabilir;
else git("kapali", undefined, kaldirilabilir);
}, [azalt, boy, burada, git, kaldirilabilir, kisikligiIsaretle, ofset, varlik, y]);
// İçerik (geç gelen veri) ya da ekran (döndürme, mobil araç çubuğu) değişince
// duraklar da kayar: sayfa durağında kalır, uçuş varsa yeniden hedeflenir.
useEffect(() => {
const sayfa = sayfaRef.current;
if (!sayfa || typeof ResizeObserver === "undefined") return;
const yenidenOlc = () => {
boy.set(sayfa.offsetHeight);
const d = hedef.current;
kisikligiIsaretle();
if (surukleme.current || d === "kapali") return;
const nokta = ofset(d);
if (Math.abs(nokta - y.get()) < 0.5 && !y.isAnimating()) return;
if (y.isAnimating()) git(d, undefined, varinca.current);
else y.jump(nokta);
};
const gozlemci = new ResizeObserver(yenidenOlc);
gozlemci.observe(sayfa);
window.addEventListener("resize", yenidenOlc);
return () => {
gozlemci.disconnect();
window.removeEventListener("resize", yenidenOlc);
};
}, [boy, git, kisikligiIsaretle, ofset, y]);
const suruklemeBasla = useCallback(
(clientY: number, an: number) => {
// Sayfayı uçarken bile bulunduğu yerden yakala; esnemiş sayfa parmak
// mesafesine geri çevrilir ki hiçbir şey sıçramasın.
y.stop();
const simdiki = y.get();
surukleme.current = {
basY: clientY,
koken: simdiki < 0 ? -lastikTersi(-simdiki, ESNEME) : simdiki,
baslangic: durakRef.current,
oynadi: false,
ornekler: [{ t: an, v: clientY }],
};
},
[y],
);
const suruklemeSur = useCallback(
(clientY: number, an: number) => {
const s = surukleme.current;
if (!s) return;
const fark = clientY - s.basY;
if (!s.oynadi) {
if (Math.abs(fark) < 3) return;
s.oynadi = true;
sayfaRef.current?.setAttribute("data-surukleniyor", "");
}
const ham = s.koken + fark;
// Aşağı birebir izler (kapatabilsin); en üstün ötesi lastik gibi direnir.
y.set(ham < 0 ? -lastik(-ham, ESNEME) : ham);
s.ornekler.push({ t: an, v: clientY });
if (s.ornekler.length > 12) s.ornekler.shift();
},
[y],
);
const suruklemeBitir = useCallback(
(an: number) => {
const s = surukleme.current;
surukleme.current = null;
sayfaRef.current?.removeAttribute("data-surukleniyor");
if (!s) return;
if (!s.oynadi) {
const d = hedef.current;
if (d !== "kapali") git(d, undefined, varinca.current);
return;
}
tiklamayiYut.current = true;
const simdiki = y.get();
// Esnemiş sayfa parmağın hızını almadan döner; esnemenin ötesine fırlamaz.
const hiz = simdiki < 0 ? 0 : birakmaHizi(s.ornekler, an);
const varis = simdiki + hiz * SUZULME;
const adaylar: Durak[] = [...kesirler.map((_, i) => i), "kapali"];
let secilen = adaylar.reduce((iyi, d) =>
Math.abs(ofset(d) - varis) < Math.abs(ofset(iyi) - varis) ? d : iyi,
);
// Hızlı fiske en az bir durak yol alır; en alçaktan aşağı fiske kapatır.
if (Math.abs(hiz) > FISKE && secilen === s.baslangic) {
const buradaPx = durakPx(s.baslangic);
if (hiz < 0) {
const yukari = kesirler.findIndex((_, i) => durakPx(i) > buradaPx + 0.5);
secilen = yukari === -1 ? s.baslangic : yukari;
} else {
let asagi = -1;
for (let i = kesirler.length - 1; i >= 0; i--) {
if (durakPx(i) < buradaPx - 0.5) {
asagi = i;
break;
}
}
secilen = asagi === -1 ? "kapali" : asagi;
}
}
// Çıkış bu karede parmağın hızıyla başlar; kapanış yalnız inmesini bekler.
if (secilen === "kapali") {
if (!azalt) git("kapali", hiz);
kapat();
return;
}
dinlen(secilen, hiz);
},
[azalt, dinlen, durakPx, git, kapat, kesirler, ofset, y],
);
// Gövdedeki dokunma: sayfa tam açık değilken çekiş sayfayı taşır; tam
// açıkken içerik kaydırır, en üstteyken aşağı çekiş yine sayfayı indirir.
useEffect(() => {
const govde = govdeRef.current;
if (!govde) return;
let dokunus: { x: number; y: number; kip: "bekliyor" | "sayfa" | "yerel" } | null = null;
const basla = (e: TouchEvent) => {
const t = e.touches[0];
const yoksay =
e.target instanceof Element && e.target.closest("[data-alt-sayfa-yoksay]");
dokunus =
e.touches.length === 1 && t && !yoksay
? { x: t.clientX, y: t.clientY, kip: "bekliyor" }
: null;
};
const sur = (e: TouchEvent) => {
const t = e.touches[0];
if (!dokunus || !t || e.touches.length !== 1) return;
const dx = t.clientX - dokunus.x;
const dy = t.clientY - dokunus.y;
if (dokunus.kip === "bekliyor") {
if (Math.hypot(dx, dy) < 4) return;
const dikey = Math.abs(dy) >= Math.abs(dx);
if (tamAcik()) {
const kayar = govde.scrollHeight > govde.clientHeight + 1;
dokunus.kip =
dikey && (!kayar || (govde.scrollTop <= 0 && dy > 0)) ? "sayfa" : "yerel";
} else {
dokunus.kip = dikey ? "sayfa" : "yerel";
}
if (dokunus.kip === "sayfa") suruklemeBasla(t.clientY, e.timeStamp);
}
if (dokunus.kip !== "sayfa") return;
if (e.cancelable) e.preventDefault();
suruklemeSur(t.clientY, e.timeStamp);
};
const bitir = (e: TouchEvent) => {
if (dokunus?.kip === "sayfa") suruklemeBitir(e.timeStamp);
dokunus = null;
};
govde.addEventListener("touchstart", basla, { passive: true });
govde.addEventListener("touchmove", sur, { passive: false });
govde.addEventListener("touchend", bitir);
govde.addEventListener("touchcancel", bitir);
return () => {
govde.removeEventListener("touchstart", basla);
govde.removeEventListener("touchmove", sur);
govde.removeEventListener("touchend", bitir);
govde.removeEventListener("touchcancel", bitir);
};
}, [suruklemeBasla, suruklemeBitir, suruklemeSur, tamAcik]);
const baglam = useMemo(() => {
const tutamak: Tutamak = {
onPointerDown(e) {
const hedefOge = e.target instanceof Element ? e.target : null;
if (e.button !== 0 || !e.isPrimary) return;
if (
hedefOge?.closest("button, a, input, select, textarea, [role='button']") &&
!hedefOge.closest("[data-tutamac]")
) {
return;
}
suruklemeBasla(e.clientY, e.timeStamp);
// Hızlı fiske ilk hareket gelmeden alandan çıkabilir: düğme dışında
// işaretçi hemen yakalanır.
if (!hedefOge?.closest("[data-tutamac]")) {
e.currentTarget.setPointerCapture(e.pointerId);
}
},
// Tutamaç düğmesinde işaretçi yalnız gerçekten oynayınca yakalanır; düz tık düğmeye düşer.
onPointerMove(e) {
if (!surukleme.current) return;
if (e.pointerType === "mouse" && e.buttons === 0) {
suruklemeBitir(e.timeStamp);
return;
}
suruklemeSur(e.clientY, e.timeStamp);
if (surukleme.current.oynadi && !e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.setPointerCapture(e.pointerId);
}
},
onPointerUp(e) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
suruklemeBitir(e.timeStamp);
},
onPointerCancel(e) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
suruklemeBitir(e.timeStamp);
},
};
return {
govdeBagla: (el: HTMLDivElement | null) => {
govdeRef.current = el;
},
// Henüz ekran dışındaki içeriğe kaydırmak ya da sekmeyle girmek sayfayı sonuna kadar açar.
govdeTekerlek: (e: WheelEvent<HTMLDivElement>) => {
if (!tamAcik() && e.deltaY > 4 && !surukleme.current) dinlen(ust);
},
govdeOdak: (e: FocusEvent<HTMLDivElement>) => {
if (tamAcik() || !(e.target instanceof Element)) return;
if (e.target.getBoundingClientRect().bottom > window.innerHeight - 8) dinlen(ust);
},
tutamak,
};
}, [dinlen, suruklemeBasla, suruklemeBitir, suruklemeSur, tamAcik, ust]);
function tutamacTik() {
if (tiklamayiYut.current) {
tiklamayiYut.current = false;
return;
}
dinlen(durakRef.current === ust ? 0 : ust);
}
function tutamacTus(e: KeyboardEvent<HTMLButtonElement>) {
const simdiki = durakRef.current;
const sonraki =
e.key === "ArrowUp"
? Math.min(simdiki + 1, ust)
: e.key === "ArrowDown"
? Math.max(simdiki - 1, 0)
: e.key === "Home"
? ust
: e.key === "End"
? 0
: null;
if (sonraki === null) return;
e.preventDefault();
dinlen(sonraki);
}
const genis = durak === ust;
return (
<>
<DialogPrimitive.Overlay asChild forceMount>
<motion.div
data-slot="alt-sayfa-perde"
// Ayrılırken altındaki hiçbir şey engellenmez: tetiğe basış kapanışı geri alabilir.
className="fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-[state=closed]:pointer-events-none!"
style={{ opacity: perde }}
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content
asChild
forceMount
onOpenAutoFocus={(e) => {
// Odak sayfanın kendisine gider: klavye açılmaz, sayfa kaymaz.
e.preventDefault();
sayfaRef.current?.focus({ preventScroll: true });
}}
onPointerDownOutside={(e) => {
onPointerDownOutside?.(e);
const { burada: simdiBurada, an } = varlikDegisimi.current;
if (!simdiBurada || e.detail.originalEvent.timeStamp < an) e.preventDefault();
}}
onInteractOutside={(e) => {
onInteractOutside?.(e);
// Bildirimdeki "Geri al"a basmak sayfayı kapatmaz.
if (e.target instanceof Element && e.target.closest("[data-bildirim-yigini]")) {
e.preventDefault();
}
}}
onKeyDown={onKeyDown}
{...props}
>
<motion.div
ref={sayfaRef}
data-slot="alt-sayfa"
className={cn(
// `after`: kenarın altına uzanan yüzey — yukarı esnemede sayfanın altında boşluk görünmez.
"group/alt-sayfa fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col rounded-t-3xl border border-b-0 border-slate-200 bg-card text-slate-900 shadow-2xl outline-none will-change-transform after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-40 after:bg-card data-[state=closed]:pointer-events-none! sm:max-w-2xl",
className,
)}
style={{ y, opacity: varlik, maxHeight: `${kesirler[ust] * 100}dvh` }}
>
<div
className="flex h-6 shrink-0 cursor-grab touch-none items-end justify-center select-none group-data-[surukleniyor]/alt-sayfa:cursor-grabbing"
{...baglam.tutamak}
>
{cokDurakli ? (
<button
type="button"
data-tutamac=""
aria-expanded={genis}
onClick={tutamacTik}
onKeyDown={tutamacTus}
className="flex h-6 w-16 cursor-[inherit] items-end justify-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="h-1.5 w-12 rounded-full bg-slate-200 transition-colors duration-150 group-data-[surukleniyor]/alt-sayfa:bg-slate-400" aria-hidden />
<span className="sr-only">{genis ? "Sayfayı küçült" : "Sayfayı genişlet"}</span>
</button>
) : (
<span className="h-1.5 w-12 rounded-full bg-slate-200 transition-colors duration-150 group-data-[surukleniyor]/alt-sayfa:bg-slate-400" aria-hidden />
)}
</div>
<SayfaBaglami.Provider value={baglam}>{children}</SayfaBaglami.Provider>
<span className="sr-only" role="status" aria-live="polite">
{duyuru}
</span>
</motion.div>
</DialogPrimitive.Content>
</>
);
}
/**
* Sayfanın kaydırılan alanı. Sayfa tam açık değilken kaydırmaz (çekiş sayfayı
* taşır); içinde kendi sürükleme hareketi olan öğe `data-alt-sayfa-yoksay` alır.
*/
function AltSayfaGovde({ className, ...props }: ComponentProps<"div">) {
const sayfa = useContext(SayfaBaglami);
return (
<div
ref={sayfa?.govdeBagla}
data-slot="alt-sayfa-govde"
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain group-data-[kisik]/alt-sayfa:touch-none group-data-[kisik]/alt-sayfa:overflow-hidden",
className,
)}
onWheel={sayfa?.govdeTekerlek}
onFocus={sayfa?.govdeOdak}
{...props}
/>
);
}
/** Kaydırmayan başlık alanı: buradan da tutulup çekilir (düğme ve linkler hariç). */
function AltSayfaSurukle({ className, ...props }: ComponentProps<"div">) {
const sayfa = useContext(SayfaBaglami);
return (
<div
data-slot="alt-sayfa-surukle"
className={cn("touch-none select-none", className)}
{...sayfa?.tutamak}
{...props}
/>
);
}
function AltSayfaBaslik({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="alt-sayfa-baslik"
className={cn("font-heading text-2xl font-bold text-slate-900", className)}
{...props}
/>
);
}
function AltSayfaAciklama({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="alt-sayfa-aciklama"
className={cn("text-sm text-slate-500", className)}
{...props}
/>
);
}
export {
AltSayfa,
AltSayfaTetik,
AltSayfaKapat,
AltSayfaIcerik,
AltSayfaGovde,
AltSayfaSurukle,
AltSayfaBaslik,
AltSayfaAciklama,
};

View File

@@ -0,0 +1,696 @@
"use client";
// Bildirim yığını (DESIGN.md §4.16). Kısa sonuç bildirimleri üst kenarda
// üst üste birikir: yenisi yukarıdan iner, eskiler arkasına saklanıp altından
// birer şerit gösterir; üzerine gelince (ya da dokununca) yığın listeye
// açılır. Açıkken ve sekme gizliyken süre durur; sağa kaydırınca kapanır.
// Aynı `id` ile ikinci çağrı yeni bildirim açmaz, olanı yerinde günceller
// (ikon, metin ve boy birlikte dönüşür).
//
// Kaynak: Arc UI "Toast stack" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
// - Yığın üstte durur (Arc altta): altta liste çekmecesinin çağrı bandı ve
// telefonda klavye var; "Geri al" bildirimi onları örtmesin.
// - Sağlayıcı yok; `toast` modül seviyesinde tek kuyruktur ve çağrı biçimi
// eski `sonner` ile aynıdır (`toast.success(başlık, { description, action,
// id, duration })`), kullanım yerleri yalnız import yolunu değiştirir.
// - Aksiyon düğmesi üst çubuktaki koyu hap; yaylar sekmesiz (§6).
//
// İç düzen Arc'ınki gibi tek hizadadır: ikon, başlığın ilk satırı, aksiyon
// hapı ve çarpı aynı 36 px'lik şeridin ortasına oturur (çarpı ve hap 36 px;
// ikon kutusu 36 px; metin 8 px üst-alt payla 20 px satır). Tek satırlık
// bildirimde hepsi kutunun tam ortasındadır, çok satırlıda ilk satırla
// hizalı kalır. Bizdeki fark: açıklaması olan bildirimde aksiyon metnin
// altına iner (Türkçe açıklamalar uzun; yanında durursa metni üç-dört satıra
// sıkıştırır) — Arc bunu dar kutuda yapar.
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import {
AnimatePresence,
animate,
motion,
useIsPresent,
useMotionValue,
usePresence,
useReducedMotion,
useTransform,
type HTMLMotionProps,
type MotionProps,
} from "motion/react";
import { CircleCheck, Info, Loader2, OctagonX, TriangleAlert, X } from "lucide-react";
import { EGRI, SURE, YAY, lastik } from "@/lib/hareket";
import { cn } from "@/lib/utils";
type Tur = "duz" | "basari" | "bilgi" | "uyari" | "hata" | "yukleniyor";
export interface BildirimSecenekleri {
/** Aynı kimlikle çağrı yeni bildirim açmaz, olanı günceller. */
id?: string | number;
description?: ReactNode;
action?: { label: ReactNode; onClick: () => void };
/** ms. Verilmezse türe göre; `Infinity` kapatılana kadar kalır. */
duration?: number;
}
type Kayit = {
id: string;
tur: Tur;
baslik: ReactNode;
aciklama?: ReactNode;
aksiyon?: BildirimSecenekleri["action"];
sure: number;
sira: number;
surum: number;
};
/** Arkaya saklanan her bildirimin öndekinin altından görünen payı (px). */
const PAY = 12;
/** Yığın açıkken bildirimler arası boşluk. */
const ARALIK = 10;
/** Saklanan bildirim her adımda bu kadar küçülür. */
const KUCULME = 0.05;
/** Aynı anda görünen bildirim; fazlası arkada bekler. */
const GORUNEN = 3;
const KUYRUK_SINIRI = 8;
const TABAN_SURE = 5000;
/** Bu kadar sağa kaydırılan bildirim büyük ölçüde sönmüştür. */
const KAYDIRMA_SONME = 280;
const TUR_ETIKETI: Record<Tur, string> = {
duz: "Bildirim",
basari: "Tamam",
bilgi: "Bilgi",
uyari: "Uyarı",
hata: "Hata",
yukleniyor: "Sürüyor",
};
const sureBul = (tur: Tur) =>
tur === "yukleniyor"
? Infinity
: tur === "uyari" || tur === "hata"
? TABAN_SURE * 1.6
: TABAN_SURE;
// ── Kuyruk (modül seviyesinde tek örnek) ────────────────────────────────
let kayitlar: Kayit[] = [];
let sayac = 0;
const dinleyiciler = new Set<() => void>();
const BOS: Kayit[] = [];
function yayinla(sonraki: Kayit[]) {
kayitlar = sonraki;
dinleyiciler.forEach((d) => d());
}
function goster(tur: Tur, baslik: ReactNode, secenekler: BildirimSecenekleri = {}): string {
const id = secenekler.id != null ? String(secenekler.id) : null;
const mevcut = id ? kayitlar.find((k) => k.id === id) : undefined;
const ortak = {
tur,
baslik,
aciklama: secenekler.description,
aksiyon: secenekler.action,
sure: secenekler.duration ?? sureBul(tur),
};
if (mevcut) {
yayinla(
kayitlar.map((k) =>
k.id === mevcut.id ? { ...k, ...ortak, surum: k.surum + 1 } : k,
),
);
return mevcut.id;
}
sayac += 1;
const kayit: Kayit = { ...ortak, id: id ?? `bildirim-${sayac}`, sira: sayac, surum: 0 };
yayinla([kayit, ...kayitlar].slice(0, KUYRUK_SINIRI));
return kayit.id;
}
function kapat(id?: string | number) {
if (id == null) {
if (kayitlar.length) yayinla([]);
return;
}
const anahtar = String(id);
if (kayitlar.some((k) => k.id === anahtar)) {
yayinla(kayitlar.filter((k) => k.id !== anahtar));
}
}
/** Aksiyon kendi bildirimini güncellemediyse (ör. "Geri al") bildirim kapanır. */
function aksiyonuCalistir(id: string) {
const kayit = kayitlar.find((k) => k.id === id);
if (!kayit?.aksiyon) return;
kayit.aksiyon.onClick();
if (kayitlar.find((k) => k.id === id)?.surum === kayit.surum) kapat(id);
}
type Goster = (baslik: ReactNode, secenekler?: BildirimSecenekleri) => string;
export const toast: Goster & {
success: Goster;
info: Goster;
warning: Goster;
error: Goster;
loading: Goster;
dismiss: typeof kapat;
} = Object.assign(
((baslik, secenekler) => goster("duz", baslik, secenekler)) as Goster,
{
success: ((b, s) => goster("basari", b, s)) as Goster,
info: ((b, s) => goster("bilgi", b, s)) as Goster,
warning: ((b, s) => goster("uyari", b, s)) as Goster,
error: ((b, s) => goster("hata", b, s)) as Goster,
loading: ((b, s) => goster("yukleniyor", b, s)) as Goster,
dismiss: kapat,
},
);
const abone = (dinleyici: () => void) => {
dinleyiciler.add(dinleyici);
return () => {
dinleyiciler.delete(dinleyici);
};
};
const gorunurlukAbone = (dinleyici: () => void) => {
document.addEventListener("visibilitychange", dinleyici);
return () => document.removeEventListener("visibilitychange", dinleyici);
};
// ── Yerleşim ─────────────────────────────────────────────────────────────
type Hedef = { y: number; olcek: number; boy: number; opaklik: number; icerik: number };
/**
* Kapalıyken saklanan bildirimler öndekinin boyunu alır ki kenarları hizalı
* dursun; açıkken her biri kendi boyunda, aralıkla alt alta dizilir. Liste,
* aradaki boşlukları da kaplar: imleç bildirimden bildirime geçerken yığın
* kapanmaz.
*/
function yigiYerlestir(liste: Kayit[], boylar: Record<string, number>, acik: boolean) {
const on = liste.length ? (boylar[liste[0].id] ?? 0) : 0;
const ustler = liste.reduce<number[]>(
(toplam, k, i) => [
...toplam,
toplam[i] + (i < GORUNEN ? (boylar[k.id] ?? 0) + ARALIK : 0),
],
[0],
);
const hedefler = liste.map((k, i): Hedef => {
const gorunur = i < GORUNEN;
const derinlik = Math.min(i, GORUNEN);
return acik
? { y: ustler[i], olcek: 1, boy: boylar[k.id] ?? 0, opaklik: gorunur ? 1 : 0, icerik: 1 }
: {
y: PAY * derinlik,
olcek: 1 - KUCULME * derinlik,
boy: i === 0 ? (boylar[k.id] ?? 0) : on,
opaklik: gorunur ? 1 : 0,
icerik: i === 0 ? 1 : 0,
};
});
const listeBoyu = !liste.length
? 0
: acik
? ustler[liste.length] - ARALIK
: on + PAY * (Math.min(liste.length, GORUNEN) - 1);
return { hedefler, listeBoyu };
}
// ── Tek bildirim ─────────────────────────────────────────────────────────
const solma = { duration: SURE.hizli, ease: EGRI.standart };
const girisSolma = { duration: SURE.standart, ease: EGRI.giris };
const yalnizOpaklik: MotionProps = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: solma },
transition: solma,
};
const metinDegisimi: MotionProps = {
initial: { opacity: 0, y: "0.3em", filter: "blur(4px)" },
animate: { opacity: 1, y: "0em", filter: "blur(0px)" },
exit: { opacity: 0, y: "-0.3em", filter: "blur(2px)", transition: solma },
transition: { duration: SURE.standart, ease: EGRI.giris },
};
const ikonDegisimi: MotionProps = {
initial: { opacity: 0, scale: 0.6 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.6, transition: solma },
transition: { ...YAY.varsayilan, opacity: solma },
};
/** Çıkan kopya sönerken ekran okuyucudan gizlenir; canlı bölge yalnız güncel metni okur. */
function Degisen(props: HTMLMotionProps<"span">) {
const burada = useIsPresent();
return <motion.span {...props} aria-hidden={burada ? props["aria-hidden"] : true} />;
}
function TurIkonu({ tur }: { tur: Tur }) {
if (tur === "basari") return <CircleCheck className="size-5 text-emerald-600" aria-hidden />;
if (tur === "bilgi") return <Info className="size-5 text-primary" aria-hidden />;
if (tur === "uyari") return <TriangleAlert className="size-5 text-amber-600" aria-hidden />;
if (tur === "hata") return <OctagonX className="size-5 text-red-600" aria-hidden />;
// Sürüyor: sitenin ortak "bekle" ikonu (§4.20); tür değişince dönen halka durur.
return (
<Loader2 className="size-5 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
);
}
// Aksiyon hapı: üst çubuktaki koyu hap, çarpıyla aynı boyda (36 px).
const AKSIYON_HAPI =
"inline-flex h-9 shrink-0 cursor-pointer items-center rounded-full bg-slate-900 px-3.5 text-sm font-semibold whitespace-nowrap text-slate-50 transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]";
type Hareket = {
isaretci: number;
basX: number;
basY: number;
koken: number;
etkin: boolean;
ornekler: { t: number; x: number }[];
};
function Bildirim({
kayit,
hedef,
acik,
onde,
gizli,
durdu,
azalt,
olcumBildir,
surukleniyor,
dokunuldu,
}: {
kayit: Kayit;
hedef: Hedef;
acik: boolean;
onde: boolean;
gizli: boolean;
durdu: boolean;
azalt: boolean;
olcumBildir: (id: string, boy: number) => void;
surukleniyor: (durum: boolean) => void;
dokunuldu: () => void;
}) {
const { id } = kayit;
const [burada, kaldirilabilir] = usePresence();
const ogeRef = useRef<HTMLLIElement>(null);
const icerikRef = useRef<HTMLDivElement>(null);
// Yığın geometrisi motion değerlerinde: yığını, parmağı ya da süreyi
// izlemek React'i kare başına yeniden çizdirmez.
const y = useMotionValue(0);
const olcek = useMotionValue(1);
const boy = useMotionValue(0);
const opaklik = useMotionValue(0);
const icerikOpakligi = useMotionValue(1);
const x = useMotionValue(0);
const kaydirmaSonmesi = useTransform(x, (v) =>
v > 0 ? 1 - Math.min(v / KAYDIRMA_SONME, 1) * 0.75 : 1,
);
/** Son hedefin uygulandığı açık/kapalı hâl; giriş bitene kadar null. */
const uygulanan = useRef<boolean | null>(null);
const kaydirildi = useRef(false);
const kalan = useRef(kayit.sure);
const hareket = useRef<Hareket | null>(null);
const tiklamayiYut = useRef(false);
const isaretciTuru = useRef("mouse");
// İlk boyamadan önce ölç: bildirim tam kendi boyu kadar yukarıdan girer.
useLayoutEffect(() => {
const dugum = icerikRef.current;
if (!dugum) return;
const olc = () => olcumBildir(id, dugum.offsetHeight + 2);
olc();
if (typeof ResizeObserver === "undefined") return;
const gozlemci = new ResizeObserver(olc);
gozlemci.observe(dugum);
return () => gozlemci.disconnect();
}, [id, olcumBildir]);
// Yığındaki her değişiklik süren yayı bulunduğu yerden yeniden hedefler;
// art arda gelen bildirimler ve hover değişimleri sıraya girmez.
useLayoutEffect(() => {
if (!burada || hedef.boy <= 0) return;
const giriyor = uygulanan.current === null;
uygulanan.current = acik;
if (azalt) {
y.jump(hedef.y);
olcek.jump(hedef.olcek);
boy.jump(hedef.boy);
animate(opaklik, hedef.opaklik, solma);
animate(icerikOpakligi, hedef.icerik, solma);
return;
}
if (giriyor) {
y.jump(hedef.y - hedef.boy);
olcek.jump(hedef.olcek);
boy.jump(hedef.boy);
icerikOpakligi.jump(hedef.icerik);
opaklik.jump(0);
}
animate(y, hedef.y, YAY.akici);
animate(olcek, hedef.olcek, YAY.akici);
if (!giriyor) animate(boy, hedef.boy, YAY.akici);
animate(opaklik, hedef.opaklik, giriyor ? girisSolma : solma);
animate(icerikOpakligi, hedef.icerik, solma);
}, [acik, azalt, boy, burada, hedef.boy, hedef.icerik, hedef.olcek, hedef.opaklik, hedef.y, icerikOpakligi, olcek, opaklik, y]);
// Güncelleme saati baştan başlatır; duraklatma kalan süreyi korur.
useEffect(() => {
kalan.current = kayit.sure;
}, [kayit.surum, kayit.sure]);
useEffect(() => {
if (!burada || durdu || !Number.isFinite(kalan.current) || kayit.sure <= 0) return;
const basladi = performance.now();
const zamanlayici = window.setTimeout(() => kapat(id), Math.max(kalan.current, 0));
return () => {
window.clearTimeout(zamanlayici);
kalan.current -= performance.now() - basladi;
};
}, [burada, durdu, id, kayit.surum, kayit.sure]);
// Çıkış: geldiği yoldan (yukarı) çekilir; fırlatılan uçmaya devam eder.
useEffect(() => {
if (burada) return;
ogeRef.current?.setAttribute("inert", "");
const bitti = () => kaldirilabilir?.();
if (azalt) {
animate(opaklik, 0, solma).then(bitti);
return;
}
if (!kaydirildi.current) {
animate(y, y.get() - 10, YAY.akici);
animate(olcek, olcek.get() * 0.96, YAY.akici);
}
animate(opaklik, 0, {
duration: kaydirildi.current ? 0.22 : SURE.cikis,
ease: EGRI.standart,
}).then(bitti);
}, [azalt, burada, kaldirilabilir, olcek, opaklik, y]);
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
isaretciTuru.current = e.pointerType;
tiklamayiYut.current = false;
if (e.button !== 0 || !burada || (e.target as Element).closest("button, a")) return;
// Hâlâ yerine dönen bildirimi tam bulunduğu yerden yakala.
x.stop();
hareket.current = {
isaretci: e.pointerId,
basX: e.clientX,
basY: e.clientY,
koken: x.get(),
etkin: false,
ornekler: [],
};
}
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
const h = hareket.current;
if (!h || h.isaretci !== e.pointerId) return;
if (!h.etkin) {
const dx = e.clientX - h.basX;
const dy = e.clientY - h.basY;
if (Math.hypot(dx, dy) < 6) return;
if (Math.abs(dy) > Math.abs(dx)) {
hareket.current = null;
return;
}
// Buradan ölç: eşiği geçerken bildirim sıçramaz.
h.etkin = true;
h.basX = e.clientX;
tiklamayiYut.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
surukleniyor(true);
}
const ham = h.koken + e.clientX - h.basX;
// Sağa parmağı birebir izler; sol yanlış yön, lastik gibi direnir.
x.set(ham >= 0 ? ham : -lastik(-ham, e.currentTarget.offsetWidth));
h.ornekler.push({ t: e.timeStamp, x: ham });
while (h.ornekler.length > 2 && e.timeStamp - h.ornekler[0].t > 100) h.ornekler.shift();
}
function birakildi(e: ReactPointerEvent<HTMLDivElement>, iptal: boolean) {
const h = hareket.current;
if (!h || h.isaretci !== e.pointerId) return;
hareket.current = null;
if (!h.etkin) return;
surukleniyor(false);
const ilk = h.ornekler[0];
const son = h.ornekler[h.ornekler.length - 1];
const hiz =
iptal || !ilk || !son || son.t === ilk.t || e.timeStamp - son.t > 60
? 0
: ((son.x - ilk.x) / (son.t - ilk.t)) * 1000;
const genislik = e.currentTarget.offsetWidth;
const konum = x.get();
// Bırakma hızının götüreceği yer (hızlı kaydırma yavaşlaması).
const varis = konum + (hiz / 1000) * 99;
if (!iptal && konum > 0 && hiz > -200 && varis > genislik * 0.4) {
kaydirildi.current = true;
if (!azalt) {
animate(x, genislik + 40, {
type: "spring",
visualDuration: 0.32,
bounce: 0,
velocity: Math.max(hiz, 0),
});
}
kapat(id);
return;
}
if (azalt) x.jump(0);
else animate(x, 0, { ...YAY.birakma, velocity: konum < 0 ? hiz * 0.3 : hiz });
}
function kartaTiklandi(e: MouseEvent<HTMLDivElement>) {
if (tiklamayiYut.current) {
tiklamayiYut.current = false;
return;
}
// Dokunmatikte hover yok: karta dokunmak yığını açar ya da kapatır.
if (isaretciTuru.current !== "mouse" && !(e.target as Element).closest("button, a")) {
dokunuldu();
}
}
function tusaBasildi(e: KeyboardEvent<HTMLLIElement>) {
if (e.key !== "Escape") return;
e.stopPropagation();
kapat(id);
}
const degisim = azalt ? yalnizOpaklik : metinDegisimi;
// Açıklama varsa aksiyon metnin altında, yoksa başlığın yanında (Arc düzeni).
const aksiyonAltta = Boolean(kayit.aciklama);
return (
<motion.li
ref={ogeRef}
// Saklanan bildirim alt kenarından küçülür: öndekinin altından tam pay kadar görünür.
className="absolute inset-x-0 top-0 origin-bottom"
style={{ y, scale: olcek, height: boy, opacity: opaklik, zIndex: kayit.sira }}
inert={gizli}
onKeyDown={tusaBasildi}
>
<motion.div
className={cn(
"absolute inset-0 touch-pan-y overflow-hidden rounded-2xl border border-slate-200 shadow-lg shadow-slate-900/5 transition-colors duration-200",
onde || acik ? "bg-card" : "bg-slate-50",
)}
style={{ x, opacity: kaydirmaSonmesi }}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={(e) => birakildi(e, false)}
onPointerCancel={(e) => birakildi(e, true)}
onClick={kartaTiklandi}
>
<motion.div
ref={icerikRef}
// Üst-alt 6 px + 36 px'lik ilk satır şeridi: tek satırlık bildirim 48 px.
className="flex items-start gap-3 py-1.5 pr-1.5 pl-4"
style={{ opacity: icerikOpakligi }}
>
{kayit.tur === "duz" ? null : (
<span className="relative grid h-9 w-5 shrink-0 place-items-center" aria-hidden>
<AnimatePresence mode="popLayout" initial={false}>
<Degisen
key={kayit.tur}
className="col-start-1 row-start-1 grid place-items-center"
{...(azalt ? yalnizOpaklik : ikonDegisimi)}
>
<TurIkonu tur={kayit.tur} />
</Degisen>
</AnimatePresence>
</span>
)}
<div className="relative min-w-0 flex-1 py-2 text-sm leading-5">
<span className="sr-only">{TUR_ETIKETI[kayit.tur]}: </span>
<span className="relative block font-semibold text-slate-900">
<AnimatePresence mode="popLayout" initial={false}>
<Degisen
key={typeof kayit.baslik === "string" ? kayit.baslik : kayit.surum}
className="block"
{...degisim}
>
{kayit.baslik}
</Degisen>
</AnimatePresence>
</span>
{kayit.aciklama ? (
<span className="mt-0.5 block text-pretty text-slate-600">{kayit.aciklama}</span>
) : null}
{kayit.aksiyon && aksiyonAltta ? (
<button
type="button"
onClick={() => aksiyonuCalistir(id)}
className={cn(AKSIYON_HAPI, "mt-2.5")}
>
{kayit.aksiyon.label}
</button>
) : null}
</div>
{/* Sağ grup: aksiyon ile çarpı yan yana, ilk satırla aynı hizada */}
<div className="flex shrink-0 items-center gap-1">
{kayit.aksiyon && !aksiyonAltta ? (
<button
type="button"
onClick={() => aksiyonuCalistir(id)}
className={AKSIYON_HAPI}
>
{kayit.aksiyon.label}
</button>
) : null}
<button
type="button"
onClick={() => kapat(id)}
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<X className="size-4" aria-hidden />
<span className="sr-only">Bildirimi kapat</span>
</button>
</div>
</motion.div>
</motion.div>
</motion.li>
);
}
// ── Görünüm alanı ────────────────────────────────────────────────────────
/** Layout'ta bir kez durur; bildirimler `toast(...)` ile her yerden gösterilir. */
export function BildirimYigini() {
const liste = useSyncExternalStore(abone, () => kayitlar, () => BOS);
const azalt = !!useReducedMotion();
const sayfaGizli = useSyncExternalStore(
gorunurlukAbone,
() => document.visibilityState === "hidden",
() => false,
);
const [uzerinde, setUzerinde] = useState(false);
const [odakta, setOdakta] = useState(false);
const [dokunulmus, setDokunulmus] = useState(false);
const [surukleme, setSurukleme] = useState(false);
const [boylar, setBoylar] = useState<Record<string, number>>({});
const bolgeRef = useRef<HTMLElement>(null);
if (dokunulmus && liste.length === 0) setDokunulmus(false);
const acik = liste.length > 1 && (uzerinde || odakta || dokunulmus);
// Tek bildirimde de üzerine gelmek süreyi durdurur (okumaya vakit kalsın).
const durdu = uzerinde || odakta || dokunulmus || surukleme || sayfaGizli;
const olcumBildir = useCallback(
(id: string, boy: number) =>
setBoylar((onceki) => (onceki[id] === boy ? onceki : { ...onceki, [id]: boy })),
[],
);
const buda = useCallback(
() =>
setBoylar((onceki) =>
Object.fromEntries(
kayitlar.filter((k) => k.id in onceki).map((k) => [k.id, onceki[k.id]]),
),
),
[],
);
const dokunuldu = useCallback(() => setDokunulmus((a) => !a), []);
useEffect(() => {
if (!dokunulmus) return;
const disari = (e: PointerEvent) => {
if (!bolgeRef.current?.contains(e.target as Node)) setDokunulmus(false);
};
document.addEventListener("pointerdown", disari);
return () => document.removeEventListener("pointerdown", disari);
}, [dokunulmus]);
const { hedefler, listeBoyu } = yigiYerlestir(liste, boylar, acik);
return (
<section
ref={bolgeRef}
// Alt sayfa (alt-sayfa.tsx) bu işaretin içindeki tıklamayı "dışarı
// tıklama" saymaz: "Geri al"a basmak çekmeceyi kapatmaz.
data-bildirim-yigini=""
aria-label="Bildirimler"
aria-live="polite"
aria-relevant="additions text"
aria-atomic="false"
className="pointer-events-none fixed inset-x-0 top-4 z-[70] mx-auto w-[min(23rem,calc(100%-2rem))] sm:top-6 print:hidden"
onFocus={(e) => {
// Yalnız klavye odağı yığını açar; fareyle düğmeye basmak açık tutmaz.
try {
if (e.target.matches(":focus-visible")) setOdakta(true);
} catch {
setOdakta(true);
}
}}
onBlur={(e) => {
if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) {
setOdakta(false);
}
}}
>
<ol
className="pointer-events-auto relative"
style={{ height: listeBoyu }}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") setUzerinde(true);
}}
onPointerLeave={(e) => {
if (e.pointerType === "mouse") setUzerinde(false);
}}
>
<AnimatePresence onExitComplete={buda}>
{liste.map((kayit, i) => (
<Bildirim
key={kayit.id}
kayit={kayit}
hedef={hedefler[i]}
acik={acik}
onde={i === 0}
gizli={i >= GORUNEN}
durdu={durdu}
azalt={azalt}
olcumBildir={olcumBildir}
surukleniyor={setSurukleme}
dokunuldu={dokunuldu}
/>
))}
</AnimatePresence>
</ol>
</section>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
// Bölme tutamacı (DESIGN.md §4.25): yan yana iki alanın arasındaki çizgiyi
// tutup sürükleyince alanlar yer değiştirir. Tutamaç işaretçiyi birebir
// izler, sınırın ötesinde lastik gibi direnir ve bırakınca sınıra döner.
// Odaklanabilir bir ayırıcıdır: ok tuşları boyutlar (Shift büyük adım),
// Home / End sınırlara gider, çift tık varsayılana döndürür.
//
// Kaynak: Arc UI "Resizable panels" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
// - Arc bölmeleri sabit boylu, kendi içinde kayan bir kap içinde yönetir.
// /listem'de sayfa kayar ve sohbet sütunu yapışkandır; bu yüzden yalnız
// tutamaç alındı — genişliği çağıran tutar (ızgaranın CSS değişkeni).
// - Bölmeyi tamamen kapatma (collapse) yok: liste de sohbet de hep görünür.
// - Dinlenirken de silik bir tutamaç görünür (fark edilsin); hover tek turuncu (§2.1).
import {
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { animate, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
import { YAY } from "@/lib/hareket";
import { cn } from "@/lib/utils";
/** Sınırın ötesine en çok bu kadar çekilebilir (px). */
const ESNEME = 44;
const ADIM = 16;
const BUYUK_ADIM = 64;
const sinirla = (v: number, alt: number, ust: number) => Math.min(ust, Math.max(alt, v));
const direnc = (tasma: number) => (tasma * ESNEME * 0.55) / (ESNEME + 0.55 * tasma);
const bant = (v: number, alt: number, ust: number) =>
v < alt ? alt - direnc(alt - v) : v > ust ? ust + direnc(v - ust) : v;
export function BolmeTutamaci({
etiket,
kontrol,
yon = 1,
oku,
yaz,
sinirlar,
onSifirla,
className,
}: {
/** Ayırıcının boyutlandırdığı alanın adı, ör. "Danışman sohbeti". */
etiket: string;
/** Boyutlanan alanın `id`'si (`aria-controls`). */
kontrol: string;
/** `1`: sağa çekmek büyütür; `-1`: sola çekmek büyütür (alan tutamacın sağındaysa). */
yon?: 1 | -1;
/** Alanın şu anki genişliği (px). */
oku: () => number;
/** Yeni genişlik (px). Sürükleme boyunca kare başına çağrılır. */
yaz: (px: number) => void;
/** O anki ekran için en dar / en geniş değer. */
sinirlar: () => { enAz: number; enCok: number };
/** Çift tık: çağıran varsayılan genişliğe döner. */
onSifirla?: () => void;
className?: string;
}) {
const azalt = useReducedMotion();
const [etkin, setEtkin] = useState(false);
const [deger, setDeger] = useState<{ simdi: number; enAz: number; enCok: number } | null>(
null,
);
const surukleme = useRef<{ isaretci: number; basX: number; koken: number; oynadi: boolean } | null>(
null,
);
const donus = useRef<AnimationPlaybackControls | null>(null);
const bildir = (px: number) => {
const { enAz, enCok } = sinirlar();
setDeger({ simdi: Math.round(px), enAz: Math.round(enAz), enCok: Math.round(enCok) });
};
/** Genişliği bulunduğu yerden hedefe yayla götürür (klavye, sınıra dönüş). */
function git(hedef: number, gecis: typeof YAY.akici | typeof YAY.birakma = YAY.akici) {
donus.current?.stop();
bildir(hedef);
const simdiki = oku();
if (azalt || Math.abs(simdiki - hedef) < 0.5) {
yaz(hedef);
return;
}
donus.current = animate(simdiki, hedef, { ...gecis, onUpdate: yaz });
}
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
if (surukleme.current || (e.pointerType === "mouse" && e.button !== 0)) return;
donus.current?.stop();
// Metin seçimi başlamasın; işaretçi hızlı çıksa da tutamaçta kalır.
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.focus({ preventScroll: true });
surukleme.current = { isaretci: e.pointerId, basX: e.clientX, koken: oku(), oynadi: false };
}
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
const s = surukleme.current;
if (!s || s.isaretci !== e.pointerId) return;
const dx = (e.clientX - s.basX) * yon;
if (!s.oynadi) {
if (Math.abs(dx) < 2) return;
s.oynadi = true;
setEtkin(true);
}
const { enAz, enCok } = sinirlar();
yaz(bant(s.koken + dx, enAz, enCok));
}
function bitti(e: ReactPointerEvent<HTMLDivElement>, iptal: boolean) {
const s = surukleme.current;
if (!s || s.isaretci !== e.pointerId) return;
surukleme.current = null;
setEtkin(false);
if (!s.oynadi) return;
const { enAz, enCok } = sinirlar();
// Bırakılan yerde kalır; sınırın ötesindeyse sınıra, iptalse başladığı yere döner.
git(iptal ? s.koken : sinirla(oku(), enAz, enCok), YAY.birakma);
}
function tus(e: KeyboardEvent<HTMLDivElement>) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
e.preventDefault();
const { enAz, enCok } = sinirlar();
const adim = (e.shiftKey ? BUYUK_ADIM : ADIM) * yon;
const simdiki = oku();
const hedef =
e.key === "Home"
? enAz
: e.key === "End"
? enCok
: sinirla(simdiki + (e.key === "ArrowRight" ? adim : -adim), enAz, enCok);
git(hedef);
}
return (
<div
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-controls={kontrol}
aria-label={`${etiket} genişliği`}
aria-valuenow={deger?.simdi}
aria-valuemin={deger?.enAz}
aria-valuemax={deger?.enCok}
aria-valuetext={deger ? `${deger.simdi} piksel` : undefined}
title="Sürükleyerek genişliği değiştir · çift tık: varsayılan"
data-etkin={etkin || undefined}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={(e) => bitti(e, false)}
onPointerCancel={(e) => bitti(e, true)}
onKeyDown={tus}
onFocus={() => bildir(oku())}
onDoubleClick={() => {
donus.current?.stop();
onSifirla?.();
}}
className={cn(
// 1 px çizgi + 24 px tutma alanı; çizgi yalnız hover / odak / sürüklemede görünür.
"group/bolme z-10 w-6 cursor-col-resize touch-none outline-none select-none",
className,
)}
>
<span
aria-hidden
className="absolute inset-y-2 left-1/2 w-px -translate-x-1/2 rounded-full bg-orange-500 opacity-0 transition-opacity duration-150 group-hover/bolme:opacity-40 group-focus-visible/bolme:opacity-40 group-data-[etkin]/bolme:opacity-70"
/>
<span
aria-hidden
className="absolute top-1/2 left-1/2 h-10 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-300 transition-[background-color,height] duration-200 group-hover/bolme:h-14 group-hover/bolme:bg-orange-500 group-focus-visible/bolme:h-14 group-focus-visible/bolme:bg-orange-500 group-data-[etkin]/bolme:h-14 group-data-[etkin]/bolme:bg-orange-500 motion-reduce:transition-none"
/>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
// Seçim çipi ve çip grubu (DESIGN.md §4.24): çok seçimli hap seçimlerinde
// (sihirbazın alan / şehir / öncelik çipleri) seçilen çip yalnız renk
// değiştirmez — başında bir tik kendini çizerek büyür, yazı yana kayar,
// çipin kenarı yayla izler ve komşular yeni yerlerine süzülür. Seçili hâl
// renk + tik'tir; renk tek başına anlam taşımaz (§2.2).
//
// Kaynak: Arc UI "Chip group" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
// - Arc komşuları layout projeksiyonuyla kaydırır; biz tik yuvasının
// genişliğini doğrudan yayla açıyoruz, komşular sayfa akışıyla kayar. Çipler
// kayan alt sayfanın ve yüksekliği ölçülen sihirbaz panelinin içinde
// yaşıyor: projeksiyon orada erken ölçümle satırları kaydırıyordu.
// - "+N daha" katlaması alınmadı (şehirlerin tamamı haritayla birlikte görünür).
// - Sınıf dizisi sitenin çipi (§4.17): 44 px, seçili mavi dolu, hover tek turuncu.
// Tek seçimde kayan vurgu isteniyorsa `SecimHaplari` (§4.22) kullanılır.
import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { YAY } from "@/lib/hareket";
import { cn } from "@/lib/utils";
/** Tik'in yazıdan aldığı yer: 14 px işaret + ardındaki boşluk. */
const YUVA = 18;
export function SecimCipi({
secili,
onClick,
children,
kucuk = false,
ton = "notr",
className,
}: {
secili: boolean;
onClick: () => void;
children: ReactNode;
/** Çok sayıda çip (iller): 40 px. */
kucuk?: boolean;
/** `bilgi`: pasif hâli bilgi mavisi tonunda (özel kontenjan durumu); seçili hâl aynı. */
ton?: "notr" | "bilgi";
className?: string;
}) {
const azalt = useReducedMotion();
const gecis = azalt ? { duration: 0 } : YAY.donusum;
return (
<button
type="button"
data-cip=""
onClick={onClick}
aria-pressed={secili}
className={cn(
"inline-flex cursor-pointer items-center rounded-full border text-sm font-semibold outline-none transition-[background-color,border-color,color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
kucuk ? "h-10 px-3.5" : "h-11 px-4",
secili
? "border-primary bg-primary text-white"
: ton === "bilgi"
? "border-primary/30 bg-primary/10 text-primary hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"
: "border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500",
className,
)}
>
{/* Tik yuvası: genişliği yayla açılır, yazı o kadar yana kayar; tik
açılan aralıkta kendini çizer, seçim kalkınca aynı yoldan geri gider. */}
<motion.span
aria-hidden
className="flex h-3.5 shrink-0 items-center overflow-hidden"
initial={false}
animate={{ width: secili ? YUVA : 0 }}
transition={gecis}
>
<svg
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<motion.path
d="M4 12.5 9.5 18 20 6.5"
initial={false}
animate={{ pathLength: secili ? 1 : 0, opacity: secili ? 1 : 0 }}
transition={gecis}
/>
</svg>
</motion.span>
{children}
</button>
);
}
/**
* Çipleri saran grup: ekran okuyucuya adını söyler, ok tuşlarıyla çipten çipe
* geçilir (Home / End uçlara gider). Yerleşim varsayılanı `flex flex-wrap gap-2`;
* ızgara isteyen yer `className` ile değiştirir.
*/
export function CipGrubu({
etiket,
className,
children,
...props
}: Omit<ComponentProps<"div">, "aria-label" | "role"> & {
/** Grubun erişilebilir adı, ör. "İlgi alanları". */
etiket: string;
}) {
function tus(e: KeyboardEvent<HTMLDivElement>) {
const cipler = Array.from(
e.currentTarget.querySelectorAll<HTMLButtonElement>("button[data-cip]"),
);
const konum = cipler.indexOf(document.activeElement as HTMLButtonElement);
if (konum < 0) return;
const son = cipler.length - 1;
const hedefler: Record<string, number> = {
ArrowRight: konum === son ? 0 : konum + 1,
ArrowDown: konum === son ? 0 : konum + 1,
ArrowLeft: konum === 0 ? son : konum - 1,
ArrowUp: konum === 0 ? son : konum - 1,
Home: 0,
End: son,
};
if (!(e.key in hedefler)) return;
e.preventDefault();
cipler[hedefler[e.key]].focus();
}
return (
<div
role="group"
aria-label={etiket}
className={cn("flex flex-wrap gap-2", className)}
onKeyDown={tus}
{...props}
>
{children}
</div>
);
}

View File

@@ -1,154 +0,0 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerHandle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Handle>) {
return (
<DrawerPrimitive.Handle
data-slot="drawer-handle"
className={cn(
"mx-auto mt-4 hidden h-1.5 w-25 shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
showSwipeHandle = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
showSwipeHandle?: boolean
}) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
{showSwipeHandle ? <DrawerHandle /> : null}
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,615 @@
"use client";
// Kart destesi (DESIGN.md §4.27): kartlar tek tek gözden geçirilir. Üstteki
// kart sağa ya da sola sürüklenip fırlatılır; aynı karar düğmelerle ve ok
// tuşlarıyla da verilir, son karar geri alınır. Arkadaki kartlar üstteki
// uzaklaştıkça yerinden kalkar. Bırakılan kart parmağın hızını taşır.
//
// Kaynak: Arc UI "Card stack" (uiarc.dev, 7 Eki 2026). Bizdeki farklar: sınıf
// dizisi sitenin kartı ve hap düğmeleri (§4.1, §4.4); onay düğmesi mavi
// (turuncu yalnız funnel CTA'sı, §2); yaylar sekmesiz, bırakılan kartın yerine
// dönüşünde tek hafif sekme (§6).
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import { Check, RotateCcw, X } from "lucide-react";
import { EGRI, SURE, YAY, lastik, lastikTersi } from "@/lib/hareket";
import { cn } from "@/lib/utils";
export type KartKarari = "sol" | "sag";
type Nokta = { x: number; y: number };
type Cikis = Nokta & { egim: number };
type Donus = Cikis & { gecikme: number };
/** Destede çizilen kart sayısı; sonuncusu bir adım geride görünmeden bekler. */
const GORUNEN = 4;
/** Arkadaki her kartın öndekinin altından görünen payı ve küçülmesi. */
const PAY = 12;
const KUCULME = 0.05;
/** Üst kart sürüklenirken arkadakilerin ne kadar yükseldiği (deste adımı). */
const KALKIS = 0.4;
const KALKIS_MESAFESI = 160;
/** Yatay her piksel için dönüş (derece). */
const DONME = 0.065;
/** Bundan hızlı bırakış (px/sn), kart az oynamış olsa da fırlatır. */
const FISKE = 500;
/** Dikey yol karar değildir: küçük serbest payın ötesinde lastik gibi direnir. */
const SERBEST_Y = 48;
const ESNEME_Y = 140;
const bosAbone = () => () => {};
const direnY = (ham: number) => {
const mesafe = Math.abs(ham);
return mesafe <= SERBEST_Y
? ham
: Math.sign(ham) * (SERBEST_Y + lastik(mesafe - SERBEST_Y, ESNEME_Y));
};
const direnYTersi = (gorunen: number) => {
const mesafe = Math.abs(gorunen);
return mesafe <= SERBEST_Y
? gorunen
: Math.sign(gorunen) * (SERBEST_Y + lastikTersi(mesafe - SERBEST_Y, ESNEME_Y));
};
const birim = (v: number) => Math.min(1, Math.max(0, v));
const FIRLATMA = { type: "spring", visualDuration: 0.5, bounce: 0 } as const;
const UCARKEN_SOLMA = { duration: 0.22, delay: 0.12, ease: EGRI.standart } as const;
const SADE_SOLMA = { duration: 0.15, ease: "linear" } as const;
function hizBul(ornekler: { t: number; x: number; y: number }[], simdi: number): Nokta {
const yakin = ornekler.filter((o) => simdi - o.t <= 80);
const ilk = yakin[0];
const son = yakin[yakin.length - 1];
if (!ilk || !son || ilk === son || simdi - son.t > 60) return { x: 0, y: 0 };
const sn = (son.t - ilk.t) / 1000;
return { x: (son.x - ilk.x) / sn, y: (son.y - ilk.y) / sn };
}
function DesteKarti({
id,
derinlik,
karar,
donus,
etkin,
zIndex,
ad,
etiketler,
kalkis,
azalt,
firlatmaHizi,
onBirak,
onFirlatildi,
onGitti,
children,
}: {
id: string;
derinlik: number;
karar: KartKarari | null;
donus?: Donus;
etkin: boolean;
zIndex: number;
ad: string;
etiketler: { sol: string; sag: string };
kalkis: MotionValue<number>;
azalt: boolean;
firlatmaHizi: (id: string) => Nokta | undefined;
onBirak: (id: string, konum: Nokta, hiz: Nokta, genislik: number) => boolean;
onFirlatildi: (id: string, cikis: Cikis) => void;
onGitti: (id: string) => void;
children: ReactNode;
}) {
const kartRef = useRef<HTMLDivElement>(null);
// Geri alınan kart ekranı terk ettiği yerden döner; hareketi azalt'ta yerinde belirir.
const yol = donus && !azalt ? donus : undefined;
const x = useMotionValue(yol?.x ?? 0);
const y = useMotionValue(yol?.y ?? 0);
const egim = useMotionValue(yol?.egim ?? 1);
const opaklik = useMotionValue(donus ? 0 : 1);
const donme = useTransform([x, egim], ([kayma, isaret]: number[]) => kayma * DONME * isaret);
// Derinlik yuvasına yayla gider; üst kart çekilirken arkadakiler kalkmaya başlar.
const derinlikHedefi = useTransform(kalkis, (v) => derinlik - (derinlik > 0 ? v * KALKIS : 0));
const derinlikYayi = useSpring(derinlikHedefi, YAY.akici);
const gorunen = azalt ? derinlikHedefi : derinlikYayi;
const olcek = useTransform(gorunen, (v) => 1 - v * KUCULME);
const inis = useTransform(gorunen, (v) => v * PAY);
const varlik = useTransform(gorunen, [GORUNEN - 2, GORUNEN - 1], [1, 0]);
const golge = useTransform(gorunen, [0, 2], [0, 1]);
const sagDamga = useTransform(x, [16, 96], [0, 1]);
const solDamga = useTransform(x, [-96, -16], [1, 0]);
const surukleme = useRef<{
ox: number;
oy: number;
ornekler: { t: number; x: number; y: number }[];
} | null>(null);
const oturdu = useRef(!donus);
const kararVerildi = useRef(false);
// Eve uçan kart desteyi rahat bırakır; arkadakiler tek harekette yer açar.
const eveGidiyor = useRef(false);
// Üst kart ne kadar yol aldığını bildirir: arkadaki deste sürüklemeye ve fırlatmaya karşılık verir.
useEffect(() => {
if (!etkin) return;
const guncelle = () => {
if (!eveGidiyor.current) {
kalkis.set(birim(Math.hypot(x.get(), y.get() * 0.5) / KALKIS_MESAFESI));
}
};
const xBirak = x.on("change", guncelle);
const yBirak = y.on("change", guncelle);
return () => {
xBirak();
yBirak();
};
}, [etkin, kalkis, x, y]);
useLayoutEffect(() => {
if (karar) {
// Tuş ya da düğme kartı sürüklenirken de karara bağlayabilir; fırlatma parmaktan devralır.
surukleme.current = null;
eveGidiyor.current = false;
kartRef.current?.removeAttribute("data-tutuluyor");
kararVerildi.current = true;
const yon = karar === "sag" ? 1 : -1;
const genislik = kartRef.current?.offsetWidth ?? 320;
const cx = x.get();
const cy = y.get();
// Düğme ve tuşlar varsayılan fiskeyle atar; sürükleme parmağın hızını korur.
const hiz = firlatmaHizi(id) ?? { x: yon * 900, y: -120 };
const surat = Math.hypot(hiz.x, hiz.y);
// Fiske seçilen yöne gidiyorsa onun boyunca, değilse sürükleme boyunca, o da yoksa dümdüz çık.
let ux = hiz.x;
let uy = hiz.y;
if (surat < 200 || Math.sign(ux) !== yon) {
ux = Math.abs(cx) > 8 ? cx : yon;
uy = Math.abs(cx) > 8 ? cy * 0.5 : -0.12;
}
const boy = Math.hypot(ux, uy) || 1;
ux /= boy;
uy /= boy;
if (Math.abs(ux) < 0.45) {
ux = yon * 0.45;
uy = Math.sign(uy || -1) * Math.sqrt(1 - 0.45 * 0.45);
}
const erim = genislik * 1.6;
const cikis = { x: cx + ux * erim, y: cy + uy * erim, egim: egim.get() };
onFirlatildi(id, cikis);
if (azalt) {
animate(opaklik, 0, { ...SADE_SOLMA, onComplete: () => onGitti(id) });
return;
}
animate(x, cikis.x, { ...FIRLATMA, velocity: hiz.x });
animate(y, cikis.y, { ...FIRLATMA, velocity: hiz.y });
animate(opaklik, 0, { ...UCARKEN_SOLMA, onComplete: () => onGitti(id) });
return;
}
// Geri al ya da çıktığı yerden ilk montaj: oradan dön, süren uçuşu yakala.
if (!kararVerildi.current && oturdu.current) return;
kararVerildi.current = false;
oturdu.current = true;
const gecikme = donus?.gecikme ?? 0;
if (azalt) {
x.jump(0);
y.jump(0);
animate(opaklik, 1, { ...SADE_SOLMA, delay: gecikme });
return;
}
eveGidiyor.current = true;
animate(x, 0, {
...YAY.akici,
delay: gecikme,
onComplete: () => {
eveGidiyor.current = false;
},
});
animate(y, 0, { ...YAY.akici, delay: gecikme });
animate(opaklik, 1, { duration: SURE.hizli, delay: gecikme, ease: EGRI.giris });
// Bu etkiyi yalnız karar sürer; dönüş konumu o anda bir kez okunur.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [karar]);
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
if (!etkin || e.button !== 0 || !e.isPrimary) return;
if (
e.target instanceof Element &&
e.target.closest("button, a, input, select, textarea, [role='button']")
) {
return;
}
x.stop();
y.stop();
eveGidiyor.current = false;
// Üst yarıdan tutulan kart elin gittiği yöne, alt yarıdan tutulan ters yöne eğilir.
if (Math.abs(x.get()) < 2) {
const kutu = e.currentTarget.getBoundingClientRect();
egim.set(e.clientY < kutu.top + kutu.height / 2 ? 1 : -1);
}
surukleme.current = {
ox: e.clientX - x.get(),
oy: e.clientY - direnYTersi(y.get()),
ornekler: [{ t: e.timeStamp, x: e.clientX, y: e.clientY }],
};
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.setAttribute("data-tutuluyor", "");
}
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
const s = surukleme.current;
if (!s || !etkin) return;
x.set(e.clientX - s.ox);
y.set(direnY(e.clientY - s.oy));
s.ornekler.push({ t: e.timeStamp, x: e.clientX, y: e.clientY });
if (s.ornekler.length > 12) s.ornekler.shift();
}
function birakildi(e: ReactPointerEvent<HTMLDivElement>) {
const s = surukleme.current;
surukleme.current = null;
e.currentTarget.removeAttribute("data-tutuluyor");
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
if (!s || !etkin) return;
const hiz =
e.type === "pointercancel" ? { x: 0, y: 0 } : hizBul(s.ornekler, e.timeStamp);
if (onBirak(id, { x: x.get(), y: y.get() }, hiz, e.currentTarget.offsetWidth)) return;
// Eşiğin altında kart bırakma hızını koruyarak yerine döner.
if (azalt) {
x.jump(0);
y.jump(0);
return;
}
animate(x, 0, { ...YAY.birakma, velocity: hiz.x });
animate(y, 0, { ...YAY.birakma, velocity: hiz.y });
}
const ucuyor = karar !== null;
return (
<motion.div
className="pointer-events-none absolute inset-x-0 top-0 h-(--kart-boyu) origin-bottom"
style={{ zIndex, scale: olcek, y: inis, opacity: varlik }}
aria-hidden={etkin ? undefined : true}
inert={!etkin}
>
<motion.div
ref={kartRef}
// Duran kart kenarlığında düz durur; eldeki ya da uçan kart gölge alır.
className={cn(
"group/kart absolute inset-0 touch-pan-y rounded-3xl select-none before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:opacity-0 before:shadow-lg before:shadow-slate-900/10 before:transition-opacity before:duration-150 data-[tutuluyor]:cursor-grabbing data-[tutuluyor]:before:opacity-100",
etkin ? "pointer-events-auto cursor-grab" : "pointer-events-none",
ucuyor && "before:opacity-100",
)}
style={{ x, y, rotate: donme, opacity: opaklik }}
role={etkin ? "group" : undefined}
aria-label={etkin ? ad : undefined}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={birakildi}
onPointerCancel={birakildi}
>
<div className="relative h-full overflow-hidden rounded-[inherit] border border-slate-200 bg-card text-slate-900">
{children}
<motion.span
className="pointer-events-none absolute inset-0 bg-slate-900/5"
style={{ opacity: golge }}
aria-hidden
/>
</div>
{etkin || ucuyor ? (
<>
<motion.span
className="pointer-events-none absolute top-4 left-4 inline-flex h-9 items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 pr-3.5 pl-2.5 text-sm font-semibold whitespace-nowrap text-primary"
style={{ opacity: sagDamga }}
aria-hidden
>
<Check className="size-4" />
{etiketler.sag}
</motion.span>
<motion.span
className="pointer-events-none absolute top-4 right-4 inline-flex h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 pr-3.5 pl-2.5 text-sm font-semibold whitespace-nowrap text-slate-700"
style={{ opacity: solDamga }}
aria-hidden
>
<X className="size-4" />
{etiketler.sol}
</motion.span>
</>
) : null}
</motion.div>
</motion.div>
);
}
const DUGME =
"inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full border px-4 text-sm font-semibold whitespace-nowrap outline-none transition-[background-color,border-color,color,opacity,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:active:scale-100";
const DUGME_TON = {
ikincil:
"border-slate-200 bg-card text-slate-800 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 aria-disabled:hover:border-slate-200 aria-disabled:hover:bg-card aria-disabled:hover:text-slate-800",
sessiz:
"border-transparent text-slate-500 hover:text-orange-500 aria-disabled:hover:text-slate-500",
onay: "border-primary bg-primary text-white hover:bg-primary/90",
} as const;
function Dugme({
ton,
pasif,
onClick,
children,
}: {
ton: keyof typeof DUGME_TON;
pasif: boolean;
onClick: () => void;
children: ReactNode;
}) {
// aria-disabled: deste bitince odak düğmede kalır, sayfaya düşmez.
return (
<button
type="button"
className={cn(DUGME, DUGME_TON[ton])}
aria-disabled={pasif || undefined}
onClick={() => {
if (!pasif) onClick();
}}
>
{children}
</button>
);
}
/** Özel boş durumu çizer; sıfırlama prop olarak gider ki çizim destenin ref'lerine dokunmasın. */
function BosYuva({
ciz,
onSifirla,
}: {
ciz: (sifirla: () => void) => ReactNode;
onSifirla: () => void;
}) {
return <>{ciz(onSifirla)}</>;
}
export function KartDestesi<T>({
ogeler,
anahtar,
ad,
kart,
onKarar,
onGeriAl,
onSifirla,
etiketler = { sol: "Geç", sag: "Al" },
etiket = "Kartlar",
bos,
className,
}: {
ogeler: T[];
anahtar: (oge: T) => string;
/** Kartın kısa adı; erişilebilir etikette ve duyurularda kullanılır. */
ad: (oge: T) => string;
kart: (oge: T) => ReactNode;
onKarar?: (oge: T, karar: KartKarari) => void;
onGeriAl?: (oge: T, karar: KartKarari) => void;
onSifirla?: () => void;
/** İki yönün eylem adı: düğmelerde ve sürüklerken beliren damgalarda. */
etiketler?: { sol: string; sag: string };
etiket?: string;
/** Tüm kartlar bitince gösterilir; verilen fonksiyon desteyi geri getirir. */
bos?: (sifirla: () => void) => ReactNode;
className?: string;
}) {
const hidrate = useSyncExternalStore(bosAbone, () => true, () => false);
const azalt = !!useReducedMotion() && hidrate;
const ipucuId = useId();
const [konum, setKonum] = useState(0);
const [gecmis, setGecmis] = useState<{ anahtar: string; karar: KartKarari }[]>([]);
const [ucanlar, setUcanlar] = useState<string[]>([]);
const [donenler, setDonenler] = useState<Record<string, Donus>>({});
const [duyuru, setDuyuru] = useState("");
const kalkis = useMotionValue(0);
// Fırlatılan kartların ekranı terk ettiği yer: geri al ve baştan başla onları oradan getirir.
const cikislar = useRef<Record<string, Cikis>>({});
const fiskeler = useRef(new Map<string, Nokta>());
const ust = ogeler[konum];
const kalan = Math.max(0, ogeler.length - konum);
function kararVer(karar: KartKarari) {
if (!ust) return;
const k = anahtar(ust);
kalkis.set(0);
setKonum(konum + 1);
setGecmis([...gecmis, { anahtar: k, karar }]);
setUcanlar((simdiki) => [...simdiki, k]);
setDonenler({});
const geriye = kalan - 1;
setDuyuru(
`${ad(ust)}: ${etiketler[karar]}. ${geriye ? `${geriye} kart kaldı.` : "Kartlar bitti."}`,
);
onKarar?.(ust, karar);
}
function geriAl() {
const son = gecmis[gecmis.length - 1];
const oge = son && ogeler.find((o) => anahtar(o) === son.anahtar);
if (!son || !oge) return;
const cikis = cikislar.current[son.anahtar];
setKonum(konum - 1);
setGecmis(gecmis.slice(0, -1));
setUcanlar((simdiki) => simdiki.filter((k) => k !== son.anahtar));
setDonenler(cikis ? { [son.anahtar]: { ...cikis, gecikme: 0 } } : {});
setDuyuru(`${ad(oge)} geri geldi.`);
onGeriAl?.(oge, son.karar);
}
function sifirla() {
if (!gecmis.length) return;
// Deste kendini geri dağıtır: en derinde duracak kart önce, üstteki en son döner.
const adet = Math.min(GORUNEN, ogeler.length);
const geri: Record<string, Donus> = {};
ogeler.slice(0, adet).forEach((oge, i) => {
const k = anahtar(oge);
const cikis = cikislar.current[k];
if (cikis && i < konum) geri[k] = { ...cikis, gecikme: (adet - 1 - i) * 0.06 };
});
setKonum(0);
setGecmis([]);
setUcanlar([]);
setDonenler(geri);
setDuyuru("Kartların hepsi destede.");
onSifirla?.();
}
function birak(k: string, yer: Nokta, hiz: Nokta, genislik: number) {
const yon = Math.sign(yer.x);
if (!yon || !ust || anahtar(ust) !== k) return false;
const fiske = Math.abs(hiz.x) > FISKE && Math.sign(hiz.x) === yon && Math.abs(yer.x) > 12;
const uzak =
Math.abs(yer.x) > Math.min(genislik * 0.35, 140) &&
!(Math.sign(hiz.x) === -yon && Math.abs(hiz.x) > 300);
if (!fiske && !uzak) return false;
fiskeler.current.set(k, hiz);
kararVer(yon > 0 ? "sag" : "sol");
return true;
}
function tus(e: KeyboardEvent<HTMLDivElement>) {
if (e.target !== e.currentTarget) return;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
kararVer(e.key === "ArrowRight" ? "sag" : "sol");
} else if (
e.key === "Backspace" ||
((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
) {
e.preventDefault();
geriAl();
}
}
const kararlar = new Map(gecmis.map((g) => [g.anahtar, g.karar]));
const pencere = new Set(ogeler.slice(konum, konum + GORUNEN).map(anahtar));
const cizilenler = ogeler.filter(
(o) => pencere.has(anahtar(o)) || ucanlar.includes(anahtar(o)),
);
const bitti = konum >= ogeler.length && ucanlar.length === 0;
return (
<div
className={cn(
"grid w-full justify-items-center gap-5 [--kart-boyu:19rem]",
className,
)}
>
<div
className="group/sahne relative isolate h-[calc(var(--kart-boyu)+24px)] w-full max-w-80 rounded-3xl outline-none"
role="group"
aria-roledescription="kart destesi"
aria-label={etiket}
aria-describedby={ipucuId}
tabIndex={0}
onKeyDown={tus}
>
{cizilenler.map((oge) => {
const id = anahtar(oge);
const sira = ogeler.indexOf(oge);
const karar = kararlar.get(id) ?? null;
const derinlik = karar ? 0 : sira - konum;
return (
<DesteKarti
key={id}
id={id}
derinlik={derinlik}
karar={karar}
donus={donenler[id]}
etkin={!karar && derinlik === 0}
zIndex={karar ? 20 + ucanlar.indexOf(id) : GORUNEN - derinlik}
ad={`${ad(oge)}, ${sira + 1} / ${ogeler.length}`}
etiketler={etiketler}
kalkis={kalkis}
azalt={azalt}
firlatmaHizi={(k) => {
const hiz = fiskeler.current.get(k);
fiskeler.current.delete(k);
return hiz;
}}
onBirak={birak}
onFirlatildi={(k, cikis) => {
cikislar.current[k] = cikis;
}}
onGitti={(k) => setUcanlar((simdiki) => simdiki.filter((u) => u !== k))}
>
{kart(oge)}
</DesteKarti>
);
})}
<AnimatePresence initial={false}>
{bitti ? (
<motion.div
key="bos"
className="absolute inset-x-0 top-0 grid h-(--kart-boyu) place-content-center justify-items-center gap-3 rounded-3xl border border-slate-200 bg-card p-6 text-center"
initial={azalt ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: SURE.anlik } }}
transition={azalt ? SADE_SOLMA : { duration: SURE.standart, ease: EGRI.giris }}
>
{bos ? (
<BosYuva ciz={bos} onSifirla={sifirla} />
) : (
<>
<p className="font-heading text-lg font-bold text-slate-900">Kartlar bitti</p>
<button
type="button"
className={cn(DUGME, DUGME_TON.ikincil)}
onClick={sifirla}
>
<RotateCcw className="size-4" aria-hidden />
Baştan başla
</button>
</>
)}
</motion.div>
) : null}
</AnimatePresence>
{/* Odak halkası sahnenin değil üst kartın çevresinde görünür */}
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 hidden h-(--kart-boyu) rounded-3xl ring-3 ring-ring/50 group-focus-visible/sahne:block"
/>
</div>
<div className="flex flex-nowrap justify-center gap-2">
<Dugme ton="ikincil" pasif={!ust} onClick={() => kararVer("sol")}>
<X className="size-4" aria-hidden />
{etiketler.sol}
</Dugme>
<Dugme ton="sessiz" pasif={!gecmis.length} onClick={geriAl}>
<RotateCcw className="size-4" aria-hidden />
Geri al
</Dugme>
<Dugme ton="onay" pasif={!ust} onClick={() => kararVer("sag")}>
<Check className="size-4" aria-hidden />
{etiketler.sag}
</Dugme>
</div>
<p id={ipucuId} className="sr-only">
Üstteki kartı sürükle ya da sol ve sağ ok tuşlarıyla karar ver. Geri tuşu son kararı
geri alır.
</p>
<p className="sr-only" role="status" aria-live="polite">
{duyuru}
</p>
</div>
);
}

View File

@@ -0,0 +1,528 @@
"use client";
// Kaydırma eylemi: liste satırını yana kaydırınca altından eylemler çıkar
// (posta kutusu gibi). Satır parmağı birebir izler; eylem genişliğinde
// durur, sonuna kadar çekilince en dıştaki eylem tüm satırı kaplar ve
// bırakınca uygulanır. Aynı anda tek satır açık kalır; başka yere dokunmak ya
// da Escape açık satırı kapatır.
//
// Kaynak: Arc UI "Swipe actions" (uiarc.dev, 7 Eki 2026). Bizdeki farklar:
// - Arc'taki "Diğer eylemler" menüsü alınmadı: eylem, satırda zaten görünen
// bir düğmeyle de yapılabilmelidir (klavye ve ekran okuyucu yolu odur;
// kaydırılan kopya `aria-hidden`).
// - Satırın listeden çıkışını çağıran yönetir (`AnimatePresence`); bu bileşen
// yalnız kaydırmayı ve eylemin satırı kaplamasını çizer.
// - Renkler palet token'ı (§2), yay sekmesiz; bırakışta tek hafif sekme (§6).
import {
createContext,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type Dispatch,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
type SetStateAction,
} from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
type AnimationPlaybackControls,
type MotionValue,
} from "motion/react";
import { YAY, lastik } from "@/lib/hareket";
import { cn } from "@/lib/utils";
export interface KaydirmaEylemi {
etiket: string;
ikon: ReactNode;
/** `tehlike`: geri dönüşü olan silme/çıkarma; etiket sonucu adıyla söyler. */
ton?: "notr" | "vurgu" | "tehlike";
/** Tam kaydırmada ya da açılan eyleme dokununca çalışır. */
onSec: () => void;
/** Eylemden sonra satır kayıp gitmez, yerine döner (ör. "Başa al"). */
satirKalir?: boolean;
}
type Yan = "sol" | "sag";
type Grup = {
acikId: string | null;
setAcikId: Dispatch<SetStateAction<string | null>>;
satirlar: Map<string, HTMLElement>;
};
type Surukleme = {
isaretci: number;
tur: string;
basX: number;
basY: number;
koken: number;
kilit: "x" | "y" | null;
ornekler: [number, number][];
};
/** Satır açık dururken tek eylemin genişliği (px). */
const EN = 80;
/** Bu kadar oynamadan basış kaydırma sayılmaz; dikeyse sayfanın kaydırmasına bırakılır. */
const BOSLUK = 8;
/** Bırakma hızının götüreceği yol (sn). */
const SUZULME = 0.1;
/** Bundan hızlı bırakış (px/sn) fiske sayılır. */
const FISKE = 900;
const TON: Record<NonNullable<KaydirmaEylemi["ton"]>, string> = {
notr: "bg-slate-200 text-slate-800",
vurgu: "bg-primary text-white",
tehlike: "bg-red-500 text-white",
};
const birim = (v: number) => Math.min(1, Math.max(0, v));
const yanBul = (v: number): Yan | null => (v > 0 ? "sol" : v < 0 ? "sag" : null);
const GrupBaglami = createContext<Grup>({
acikId: null,
setAcikId: () => {},
satirlar: new Map(),
});
/** Satırları sarar: aynı anda yalnız biri açık kalır. DOM üretmez. */
export function KaydirmaGrubu({ children }: { children: ReactNode }) {
const [acikId, setAcikId] = useState<string | null>(null);
const [satirlar] = useState(() => new Map<string, HTMLElement>());
useEffect(() => {
if (!acikId) return;
const basildi = (e: PointerEvent) => {
const satir = satirlar.get(acikId);
if (satir && e.target instanceof Node && satir.contains(e.target)) return;
setAcikId(null);
};
const tus = (e: KeyboardEvent) => {
if (e.key === "Escape") setAcikId(null);
};
document.addEventListener("pointerdown", basildi, true);
document.addEventListener("keydown", tus);
return () => {
document.removeEventListener("pointerdown", basildi, true);
document.removeEventListener("keydown", tus);
};
}, [acikId, satirlar]);
const grup = useMemo(() => ({ acikId, setAcikId, satirlar }), [acikId, satirlar]);
return <GrupBaglami.Provider value={grup}>{children}</GrupBaglami.Provider>;
}
function hizBul(ornekler: [number, number][]) {
const son = ornekler[ornekler.length - 1];
const ilk = ornekler.find((o) => son[0] - o[0] <= 80) ?? son;
const gecen = (son[0] - ilk[0]) / 1000;
return gecen > 0 ? (son[1] - ilk[1]) / gecen : 0;
}
/**
* Tek satır. `sol` eylemler sol kenarın, `sag` eylemler sağ kenarın altında
* durur; her yanda en dıştaki eylem tam kaydırmayla uygulanır.
*/
export function KaydirmaSatiri({
sol = [],
sag = [],
tamKaydirma = true,
children,
className,
icerikSinifi,
}: {
sol?: KaydirmaEylemi[];
sag?: KaydirmaEylemi[];
/** Uzun kaydırma en dıştaki eylemi dokunmadan uygular. */
tamKaydirma?: boolean;
children: ReactNode;
className?: string;
/** Kayan içerik kabı: zemin (`bg-card`) ve iç yerleşim buradan. */
icerikSinifi?: string;
}) {
const { acikId, setAcikId, satirlar } = useContext(GrupBaglami);
const id = useId();
const azalt = useReducedMotion() ?? false;
const satirRef = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const kaplama = useMotionValue(0);
const [kaplayan, setKaplayan] = useState<{ yan: Yan; sira: number } | null>(null);
const genislik = useRef(0);
const surukleme = useRef<Surukleme | null>(null);
const yolculuk = useRef<AnimationPlaybackControls | null>(null);
const gerilme = useRef<AnimationPlaybackControls | null>(null);
const kurulu = useRef(false);
const ayriliyor = useRef(false);
const tiklamayiYut = useRef(false);
const geriZamanlayici = useRef(0);
const eylemler = (yan: Yan) => (yan === "sol" ? sol : sag);
const acikEn = (yan: Yan) => eylemler(yan).length * EN;
const enDis = (yan: Yan) => (yan === "sol" ? 0 : sag.length - 1);
const uygulanabilir = (yan: Yan) => tamKaydirma && eylemler(yan).length > 0;
const esik = (yan: Yan) => Math.max(acikEn(yan) + 48, genislik.current * 0.56);
function ger(deger: number) {
gerilme.current?.stop();
if (azalt) {
kaplama.jump(deger);
return;
}
gerilme.current = animate(kaplama, deger, YAY.donusum);
}
/** Satırı bir durağa yayla oturtur; bırakma hızını taşır. */
function otur(hedef: number, hiz = 0) {
if (kurulu.current || (hedef !== 0 && kaplama.get() !== 0)) ger(0);
kurulu.current = false;
const nereden = x.get();
const evde = () => {
if (hedef === 0) {
gerilme.current?.stop();
kaplama.jump(0);
}
};
if (azalt) {
x.jump(hedef);
evde();
} else {
// Eve dönerken yay satırı sıfırın ötesine taşımamalı; karşı yanın eylemleri parlar.
const eveDogru = hedef === 0 && Math.sign(hiz) === -Math.sign(nereden);
const cikis = eveDogru
? Math.sign(hiz) * Math.min(Math.abs(hiz), Math.abs(nereden) * 12)
: hiz;
yolculuk.current = animate(x, hedef, {
...(hedef === 0 ? YAY.akici : YAY.birakma),
velocity: cikis,
onComplete: evde,
});
}
if (hedef === 0) setAcikId((simdiki) => (simdiki === id ? null : simdiki));
else setAcikId(id);
}
/** Seçilen eylem tüm satırı kaplarken satır onun yönünde çıkar. */
function uygula(yan: Yan, sira: number, hiz = 0) {
const eylem = eylemler(yan)[sira];
if (!eylem || ayriliyor.current) return;
const yon = yan === "sol" ? 1 : -1;
kurulu.current = false;
setKaplayan({ yan, sira });
ger(1);
if (eylem.satirKalir) {
eylem.onSec();
otur(0, hiz);
return;
}
ayriliyor.current = true;
yolculuk.current?.stop();
const son = yon * (genislik.current + 2);
if (azalt) x.jump(son);
else {
yolculuk.current = animate(x, son, {
...YAY.akici,
velocity: Math.sign(hiz) === yon ? hiz : 0,
});
}
setAcikId((simdiki) => (simdiki === id ? null : simdiki));
eylem.onSec();
// Öğeyi listede tutan çağıran, boş bir renk bandı yerine satırını geri alır.
window.clearTimeout(geriZamanlayici.current);
geriZamanlayici.current = window.setTimeout(() => {
if (!ayriliyor.current) return;
ayriliyor.current = false;
otur(0);
}, 1400);
}
function kur(deger: number, isaretciTuru: string) {
const yan = yanBul(deger);
const sinir = yan ? esik(yan) : Infinity;
const sonraki =
!!yan && uygulanabilir(yan) && Math.abs(deger) > (kurulu.current ? sinir - 20 : sinir);
if (sonraki === kurulu.current || !yan) {
if (!yan && kurulu.current) {
kurulu.current = false;
ger(0);
}
return;
}
kurulu.current = sonraki;
if (sonraki) {
setKaplayan({ yan, sira: enDis(yan) });
if (isaretciTuru === "touch") navigator.vibrate?.(8);
}
ger(sonraki ? 1 : 0);
}
function sinirla(ham: number) {
const yan = yanBul(ham);
if (!yan) return 0;
const olcu = genislik.current || 320;
const sinir =
eylemler(yan).length === 0 ? 0 : uygulanabilir(yan) ? olcu : acikEn(yan);
const mesafe = Math.abs(ham);
return Math.sign(ham) * (mesafe <= sinir ? mesafe : sinir + lastik(mesafe - sinir, olcu));
}
function birak(hiz: number) {
const deger = x.get();
const yan = yanBul(deger);
if (!yan || eylemler(yan).length === 0) {
otur(0, hiz);
return;
}
const yon = yan === "sol" ? 1 : -1;
const mesafe = Math.abs(deger);
const disari = hiz * yon;
const acik = acikEn(yan);
const varis = mesafe + disari * SUZULME;
const firlatildi = mesafe > acik && disari > FISKE && varis > esik(yan);
// Eve doğru fiske, kurulmuş tam kaydırmayı iptal eder.
if (uygulanabilir(yan) && ((kurulu.current && disari > -FISKE) || firlatildi)) {
uygula(yan, enDis(yan), hiz);
return;
}
otur(varis > acik / 2 ? yon * acik : 0, hiz);
}
function basildi(e: ReactPointerEvent<HTMLDivElement>) {
tiklamayiYut.current = false;
if (ayriliyor.current || !e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) {
return;
}
// Satırı uçarken yakala: sonraki hareket bulunduğu yerden sürer.
yolculuk.current?.stop();
surukleme.current = {
isaretci: e.pointerId,
tur: e.pointerType,
basX: e.clientX,
basY: e.clientY,
koken: x.get(),
kilit: null,
ornekler: [[e.timeStamp, e.clientX]],
};
}
function oynadi(e: ReactPointerEvent<HTMLDivElement>) {
const s = surukleme.current;
if (!s || s.isaretci !== e.pointerId || s.kilit === "y") return;
if (!s.kilit) {
const dx = e.clientX - s.basX;
const dy = e.clientY - s.basY;
if (Math.hypot(dx, dy) < BOSLUK) return;
if (Math.abs(dy) > Math.abs(dx)) {
s.kilit = "y";
return;
}
// Buradan ölç: boşluğu aşarken satır sıçramaz.
s.kilit = "x";
s.basX = e.clientX;
e.currentTarget.setPointerCapture(e.pointerId);
setAcikId(id);
}
const deger = sinirla(s.koken + e.clientX - s.basX);
x.set(deger);
s.ornekler.push([e.timeStamp, e.clientX]);
if (s.ornekler.length > 12) s.ornekler.shift();
kur(deger, s.tur);
}
function bitti(e: ReactPointerEvent<HTMLDivElement>) {
const s = surukleme.current;
if (!s || s.isaretci !== e.pointerId) return;
surukleme.current = null;
if (s.kilit === "x") {
tiklamayiYut.current = true;
s.ornekler.push([e.timeStamp, e.clientX]);
birak(e.type === "pointercancel" ? 0 : hizBul(s.ornekler));
return;
}
// Açık satıra dokunmak, altındakine basmak yerine satırı kapatır.
if (s.kilit === null && Math.abs(x.get()) > 0.5) {
tiklamayiYut.current = true;
otur(0);
}
}
function tiklamaYakala(e: ReactMouseEvent<HTMLDivElement>) {
if (!tiklamayiYut.current) return;
tiklamayiYut.current = false;
e.preventDefault();
e.stopPropagation();
}
useEffect(() => {
const satir = satirRef.current;
if (!satir) return;
satirlar.set(id, satir);
genislik.current = satir.offsetWidth;
const gozlemci = new ResizeObserver(([kayit]) => {
genislik.current = kayit.contentRect.width;
});
gozlemci.observe(satir);
return () => {
gozlemci.disconnect();
satirlar.delete(id);
window.clearTimeout(geriZamanlayici.current);
setAcikId((simdiki) => (simdiki === id ? null : simdiki));
};
}, [id, satirlar, setAcikId]);
// Başka satır açıldı ya da dışarı dokunuldu: bu satır kapanır.
const kapanRef = useRef(() => {});
useEffect(() => {
kapanRef.current = () => {
if (
acikId !== id &&
!ayriliyor.current &&
surukleme.current?.kilit !== "x" &&
x.get() !== 0
) {
otur(0);
}
};
});
useEffect(() => {
kapanRef.current();
}, [acikId]);
return (
<div ref={satirRef} className={cn("relative isolate overflow-hidden", className)}>
{(["sol", "sag"] as const).map((yan) =>
eylemler(yan).map((eylem, sira) => {
const adet = eylemler(yan).length;
const kat = yan === "sol" ? adet - 1 - sira : sira;
const kaplayanKat =
kaplayan?.yan === yan
? yan === "sol"
? adet - 1 - kaplayan.sira
: kaplayan.sira
: null;
return (
<EylemKatmani
key={`${yan}-${sira}`}
eylem={eylem}
yan={yan}
kat={kat}
adet={adet}
kaplayanKat={kaplayanKat}
x={x}
kaplama={kaplama}
onBas={() => uygula(yan, sira)}
/>
);
}),
)}
<motion.div
// Dikey kaydırma sayfada kalır; içerik parmağı yalnız transform'la izler.
className={cn("relative z-10 touch-pan-y select-none", icerikSinifi)}
style={{ x }}
onPointerDown={basildi}
onPointerMove={oynadi}
onPointerUp={bitti}
onPointerCancel={bitti}
onClickCapture={tiklamaYakala}
onDragStart={(e) => e.preventDefault()}
>
{children}
</motion.div>
</div>
);
}
/**
* Açılan tek eylem. Kat 0 içeriğe yaslıdır, en yüksek kat satırın dış
* kenarındadır. Her katman satırın tamamı kadardır ve yalnız transform'la
* kayar: eylemler açılan alanı eşit paylaşır, kaplayan eylem satır boyunca gerilir.
*/
function EylemKatmani({
eylem,
yan,
kat,
adet,
kaplayanKat,
x,
kaplama,
onBas,
}: {
eylem: KaydirmaEylemi;
yan: Yan;
kat: number;
adet: number;
kaplayanKat: number | null;
x: MotionValue<number>;
kaplama: MotionValue<number>;
onBas: () => void;
}) {
const yon = yan === "sol" ? 1 : -1;
const pay = useTransform(x, (v) => Math.max(0, v * yon) / adet);
// Satırın dış kenarından bu katmanın iç kenarına uzaklık.
const ic = useTransform([x, kaplama], ([v, ilerleme]: number[]) => {
const acilan = Math.max(0, v * yon);
const taban = ((adet - kat) * acilan) / adet;
if (kaplayanKat === kat) return taban + ilerleme * (acilan - taban);
if (kaplayanKat !== null && kat > kaplayanKat) return taban * (1 - ilerleme);
return taban;
});
const kayma = useTransform(ic, (v) => yon * v);
// İşaret payının ortasında gider; bu eylem satırı kaplarken içerik kenarına yaslanır.
const isaretX = useTransform([pay, kaplama], ([p, ilerleme]: number[]) =>
-yon * (kaplayanKat === kat ? p / 2 + ilerleme * (EN / 2 - p / 2) : p / 2),
);
const ikonGorunum = useTransform([pay, kaplama], ([p, ilerleme]: number[]) => {
const kendi = birim((p - EN * 0.25) / (EN * 0.55));
if (kaplayanKat === kat) return Math.max(kendi, ilerleme);
return kaplayanKat === null ? kendi : kendi * (1 - ilerleme);
});
const etiketGorunum = useTransform([pay, kaplama], ([p, ilerleme]: number[]) => {
const kendi = birim((p - EN * 0.72) / (EN * 0.24));
if (kaplayanKat === kat) return Math.max(kendi, ilerleme);
return kaplayanKat === null ? kendi : kendi * (1 - ilerleme);
});
const ikonOlcek = useTransform(ikonGorunum, (v) => 0.6 + 0.4 * v);
return (
<motion.button
type="button"
// Erişilebilir yol satırdaki görünür düğmedir; bu kopya sekme sırasına ve
// erişilebilirlik ağacına girmez.
tabIndex={-1}
aria-hidden
className={cn(
"absolute inset-y-0 w-full cursor-pointer outline-none",
yan === "sag" ? "left-full" : "right-full",
TON[eylem.ton ?? "notr"],
)}
style={{ x: kayma, zIndex: kat + 1 }}
onClick={onBas}
>
<motion.span
className={cn("absolute inset-y-0 w-0", yan === "sag" ? "left-0" : "right-0")}
style={{ x: isaretX }}
>
<span
className="absolute inset-y-0 flex flex-col items-center justify-center gap-0.5"
style={{ left: -EN / 2, width: EN }}
>
<motion.span
className="grid place-items-center [&_svg]:size-5"
style={{ opacity: ikonGorunum, scale: ikonOlcek }}
>
{eylem.ikon}
</motion.span>
<motion.span
className="text-xs font-semibold whitespace-nowrap"
style={{ opacity: etiketGorunum }}
>
{eylem.etiket}
</motion.span>
</span>
</motion.span>
</motion.button>
);
}

View File

@@ -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<typeof QuestionnairePrimitive.Root>) {
return (
<QuestionnairePrimitive.Root
data-slot="questionnaire"
className={cn("flex w-full min-w-0 flex-col gap-4", className)}
{...props}
/>
);
}
function QuestionnaireProgress({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) {
return (
<QuestionnairePrimitive.Progress
data-slot="questionnaire-progress"
className={cn(
"min-h-[1lh] w-fit min-w-[14ch] text-xs font-semibold text-slate-500 tabular-nums",
className,
)}
{...props}
/>
);
}
function QuestionnaireItem({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
return (
<QuestionnairePrimitive.Item
data-slot="questionnaire-item"
className={cn("flex min-w-0 flex-col gap-3 border-0 p-0 outline-none", className)}
{...props}
/>
);
}
function QuestionnaireTitle({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
return (
<QuestionnairePrimitive.Title
data-slot="questionnaire-title"
className={cn(
"font-heading text-base leading-snug font-bold text-pretty text-slate-900 [&:not(:has(~[data-slot=questionnaire-description]))]:mb-1",
className,
)}
{...props}
/>
);
}
function QuestionnaireDescription({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) {
return (
<QuestionnairePrimitive.Description
data-slot="questionnaire-description"
className={cn("text-sm text-pretty leading-5 text-slate-600", className)}
{...props}
/>
);
}
function QuestionnaireChoices({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) {
return (
<QuestionnairePrimitive.Choices
data-slot="questionnaire-choices"
className={cn("group/questionnaire-choices grid min-w-0 gap-2", className)}
{...props}
/>
);
}
/**
* 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<typeof QuestionnairePrimitive.Choice>) {
return (
<QuestionnairePrimitive.Choice
data-slot="questionnaire-choice"
className={cn(
"group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-2xl border border-slate-200 bg-card px-3.5 py-2.5 text-start text-sm font-semibold text-slate-700 transition-[background-color,border-color,color,transform] duration-150 outline-none select-none",
"hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.99]",
"has-[>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}
>
<QuestionnairePrimitive.ChoiceInput
data-slot="questionnaire-choice-input"
className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span
aria-hidden="true"
data-slot="questionnaire-choice-indicator"
className="pointer-events-none relative mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-slate-300 bg-card group-data-[type=radio]/questionnaire-choice:rounded-full group-data-checked/questionnaire-choice:border-primary-foreground group-data-checked/questionnaire-choice:bg-primary-foreground group-data-checked/questionnaire-choice:text-primary"
>
<Check
data-slot="questionnaire-choice-indicator-check"
className="hidden size-3 group-data-checked/questionnaire-choice:block"
strokeWidth={3}
/>
</span>
<QuestionnairePrimitive.ChoiceLabel
data-slot="questionnaire-choice-label"
className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"
>
{children}
</QuestionnairePrimitive.ChoiceLabel>
<QuestionnairePrimitive.ChoiceShortcut
data-slot="questionnaire-choice-shortcut"
className="pointer-events-none ms-auto mt-0.5 hidden size-5 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-card font-mono text-[0.625rem] leading-none font-medium text-slate-500 group-data-checked/questionnaire-choice:border-white/40 group-data-checked/questionnaire-choice:bg-white/15 group-data-checked/questionnaire-choice:text-white group-data-[shortcut]/questionnaire-choice:inline-flex"
/>
</QuestionnairePrimitive.Choice>
);
}
function QuestionnaireChoiceDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="questionnaire-choice-description"
className={cn(
"text-xs font-normal text-slate-500 group-data-checked/questionnaire-choice:text-white/80",
className,
)}
{...props}
/>
);
}
/** Serbest cevap alanı: §4.6 metin alanı hapı (44 px, bu kutuda bir kademe kısa). */
function QuestionnaireInput({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
return (
<div
data-slot="questionnaire-input-wrapper"
className="group/questionnaire-input relative w-full min-w-0"
>
<QuestionnairePrimitive.Input
data-slot="questionnaire-input"
className={cn(
"h-11 w-full min-w-0 rounded-full border border-slate-200 bg-card px-4 text-base text-slate-900 transition-[border-color,box-shadow] outline-none placeholder:text-slate-400 hover:border-slate-300 focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-50 aria-invalid:border-red-500 aria-invalid:ring-3 aria-invalid:ring-red-500/20",
className,
)}
{...props}
/>
</div>
);
}
function QuestionnaireError({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
return (
<QuestionnairePrimitive.Error
data-slot="questionnaire-error"
className={cn("text-sm font-medium text-red-600", className)}
{...props}
/>
);
}
function QuestionnaireActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="questionnaire-actions"
className={cn(
"grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2",
className,
)}
{...props}
/>
);
}
type DugmeProps = Pick<React.ComponentProps<typeof Button>, "size" | "variant">;
function QuestionnairePrevious({
children,
className,
size = "default",
variant = "outline",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> & DugmeProps) {
return (
<QuestionnairePrimitive.Previous
data-slot="questionnaire-previous"
className={cn(
buttonVariants({ size, variant }),
"col-start-1 row-start-1 cursor-pointer justify-self-start",
className,
)}
{...props}
>
{children ?? "Geri"}
</QuestionnairePrimitive.Previous>
);
}
function QuestionnaireSkip({
children,
className,
size = "default",
variant = "ghost",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> & DugmeProps) {
return (
<QuestionnairePrimitive.Skip
data-slot="questionnaire-skip"
className={cn(
buttonVariants({ size, variant }),
"col-start-2 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "Atla"}
</QuestionnairePrimitive.Skip>
);
}
function QuestionnaireNext({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Next> & DugmeProps) {
return (
<QuestionnairePrimitive.Next
data-slot="questionnaire-next"
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "İleri"}
</QuestionnairePrimitive.Next>
);
}
function QuestionnaireSubmit({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> & DugmeProps) {
return (
<QuestionnairePrimitive.Submit
data-slot="questionnaire-submit"
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 cursor-pointer justify-self-end",
className,
)}
{...props}
>
{children ?? "Gönder"}
</QuestionnairePrimitive.Submit>
);
}
export {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
};

View File

@@ -1,54 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon } from "lucide-react"
/**
* Bildirimler (DESIGN.md §4.16). Tema sitenin temasını izler (sistemin
* değil): sonner kendi "system" algısıyla açık sitede koyu toast metni
* basıp açıklamayı okunmaz ediyordu (Bilal, 24 Eyl 2026: "toast açıklaması
* okunmuyor"). Metin ve buton renkleri globals.css'te.
*/
const Toaster = ({ ...props }: ToasterProps) => {
const { resolvedTheme } = useTheme()
return (
<Sonner
theme={resolvedTheme === "dark" ? "dark" : "light"}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<span className="block size-2.5 animate-pulse rounded-full bg-current" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,149 @@
"use client";
// Yazı dönüşümü (DESIGN.md §4.26): kısa bir ifade yerinde bir sonrakine
// dönüşür. İki ifadenin ortak harfleri yeni yerlerine süzülür, yeni harfler
// netleşerek gelir, giden harfler bulanıp söner; genişlik yayla izler ki
// çevredeki yazı sıçramadan kaysın. Tek satırda kalır; ekran okuyucu düz
// metni okur.
//
// Kaynak: Arc UI "Text morph" (uiarc.dev, 7 Eki 2026). Yay sekmesiz (§6);
// hareketi azalt'ta yazı geçişsiz değişir.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
AnimatePresence,
animate,
motion,
useReducedMotion,
type AnimationPlaybackControls,
} from "motion/react";
import { EGRI, SURE, YAY } from "@/lib/hareket";
/** Giden harfler, yerlerine ilk yeni harf netleşmeden önce silinmeye başlar. */
const DEVIR = 0.05;
const HARF_ARALIGI = 0.032;
/** Harfler karakter + kaçıncı geçişi olduğuyla eşleşir: ikinci "i" ikinci "i" ile. */
function harflereBol(metin: string) {
const gorulen = new Map<string, number>();
return Array.from(metin).map((harf) => {
const adet = gorulen.get(harf) ?? 0;
gorulen.set(harf, adet + 1);
return { harf, anahtar: `${harf}-${adet}` };
});
}
const olc = (el: HTMLElement) => parseFloat(getComputedStyle(el).width);
export function YaziDonusumu({
children,
className,
}: {
children: string;
className?: string;
}) {
const azalt = useReducedMotion();
const cerceve = useRef<HTMLSpanElement>(null);
const serit = useRef<HTMLSpanElement>(null);
const genislik = useRef(0);
const boyutlama = useRef<AnimationPlaybackControls | null>(null);
const harfler = useMemo(() => harflereBol(children), [children]);
// Önceki ifade hangi harflerin yeni olduğunu söyler; yalnız onlar sırayla gelir.
const [ifadeler, setIfadeler] = useState({ simdiki: children, onceki: children });
if (ifadeler.simdiki !== children) {
setIfadeler({ simdiki: children, onceki: ifadeler.simdiki });
}
const kalanlar = useMemo(
() => new Set(harflereBol(ifadeler.onceki).map((h) => h.anahtar)),
[ifadeler.onceki],
);
let giren = 0;
// Çerçeve açık bir genişlik taşır: yeni ifade onu sıçratmaz, yay şeridi izler.
useLayoutEffect(() => {
const c = cerceve.current;
const s = serit.current;
if (!c || !s) return;
const sonraki = olc(s);
if (!Number.isFinite(sonraki)) return;
if (genislik.current && Math.abs(sonraki - genislik.current) > 0.5 && !azalt) {
boyutlama.current = animate(c, { width: sonraki }, YAY.donusum);
} else {
boyutlama.current?.stop();
c.style.width = `${sonraki}px`;
}
genislik.current = sonraki;
}, [children, azalt]);
// Font yüklenmesi ya da duyarlı yazı boyu genişliği ifade değişmeden değiştirir: hemen izle.
useEffect(() => {
const c = cerceve.current;
const s = serit.current;
if (!c || !s || typeof ResizeObserver === "undefined") return;
const gozlemci = new ResizeObserver(() => {
const sonraki = olc(s);
if (!Number.isFinite(sonraki) || Math.abs(sonraki - genislik.current) < 0.5) return;
boyutlama.current?.stop();
c.style.width = `${sonraki}px`;
genislik.current = sonraki;
});
gozlemci.observe(s);
return () => gozlemci.disconnect();
}, []);
return (
<span className={className}>
<span className="sr-only">{children}</span>
<span
ref={cerceve}
aria-hidden
// Dolgu + eksi kenar boşluğu: maskeye bulanıklık ve kuyruklu harfler
// için yer açar, yerleşimi değiştirmez. Şerit hep çerçevenin başından başlar.
className="relative -my-[0.5em] -mr-[0.5em] -ml-[0.2em] box-content inline-block py-[0.5em] pr-[0.5em] pl-[0.2em] text-start whitespace-nowrap [mask-image:linear-gradient(90deg,#000_calc(100%-0.5em),transparent)]"
>
<span ref={serit} className="relative inline-block whitespace-nowrap">
<AnimatePresence mode="popLayout" initial={false}>
{harfler.map(({ harf, anahtar }) => {
const sira = kalanlar.has(anahtar) ? 0 : giren++;
return (
<motion.span
key={anahtar}
layout="position"
className="inline-block origin-[50%_60%] whitespace-pre"
initial={
azalt ? false : { opacity: 0, scale: 0.8, y: "0.14em", filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, y: "0em", filter: "blur(0px)" }}
exit={
azalt
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
scale: 0.86,
y: "-0.1em",
filter: "blur(4px)",
transition: { duration: SURE.cikis, ease: EGRI.standart },
}
}
transition={
azalt
? { duration: 0 }
: {
layout: YAY.donusum,
default: {
duration: SURE.standart + 0.04,
ease: EGRI.giris,
delay: DEVIR + Math.min(sira, 8) * HARF_ARALIGI,
},
}
}
>
{harf === " " ? " " : harf}
</motion.span>
);
})}
</AnimatePresence>
</span>
</span>
</span>
);
}

View File

@@ -4,13 +4,14 @@ import { useLayoutEffect, useRef, type ReactNode } from "react";
import { animate, useReducedMotion } from "motion/react";
import { ChevronRight, GraduationCap, X } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
AltSayfa,
AltSayfaKapat,
AltSayfaGovde,
AltSayfaIcerik,
AltSayfaAciklama,
AltSayfaBaslik,
AltSayfaTetik,
} from "@/components/ui/alt-sayfa";
/**
* Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı
@@ -38,8 +39,8 @@ export function AkademikAyrinti({
children: ReactNode;
}) {
return (
<Drawer handleOnly>
<DrawerTrigger asChild>
<AltSayfa>
<AltSayfaTetik asChild>
<button
type="button"
className="mt-4 flex w-full cursor-pointer items-center gap-4 group rounded-2xl border border-slate-200 bg-card p-4 text-left transition-[background-color,border-color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
@@ -62,23 +63,23 @@ export function AkademikAyrinti({
aria-hidden
/>
</button>
</DrawerTrigger>
<DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl">
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
</AltSayfaTetik>
<AltSayfaIcerik>
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
</AltSayfaKapat>
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{baslik}
</DrawerTitle>
<DrawerDescription className="mt-1 px-10 text-center text-sm text-slate-500">
</AltSayfaBaslik>
<AltSayfaAciklama className="mt-1 px-10 text-center text-sm text-slate-500">
{baglam}
</DrawerDescription>
</AltSayfaAciklama>
<AcilisSahnesi>{children}</AcilisSahnesi>
</div>
</DrawerContent>
</Drawer>
</AltSayfaGovde>
</AltSayfaIcerik>
</AltSayfa>
);
}

View File

@@ -5,7 +5,6 @@ import {
Armchair,
Briefcase,
Calculator,
ChartLine,
ChevronRight,
GraduationCap,
Landmark,
@@ -35,11 +34,7 @@ import { VeriKaynagi } from "./veri-kaynagi";
import { NavBaslik } from "./nav-baslik";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import {
KartBaglantisi,
kisaUniAdi,
ortaTabanAciklamasi,
} from "./taban-karti";
import { KartBaglantisi, kisaUniAdi } from "./taban-karti";
import { trBaslikDuzeni } from "@/lib/slug";
import {
BOLUM_ISTIHDAM_KAYNAK_URL,
@@ -106,7 +101,7 @@ function MezunIstihdami({
const istihdamKaresi =
veri.istihdam != null ? (
<div data-kare="sol" className={`${kare} min-h-40 bg-orange-500 py-4`}>
<dt className={etiket}>Kayıtlı çalışan</dt>
<dt className={etiket}>İşe yerleşen</dt>
<dd className={`mt-3 ${devSayi}`}>%{ondalik(veri.istihdam)}</dd>
{ort.istihdam != null ? (
<dd className="mt-2 text-base font-bold text-white/90">
@@ -165,7 +160,7 @@ function MezunIstihdami({
>
Türkiye geneli, tüm üniversitelerin{" "}
{duzey === "lisans" ? "lisans" : "ön lisans"} mezunları birlikte;
üniversiteye göre değişir. Kayıtlı çalışan, sigortalı bir işte
üniversiteye göre değişir. İşe yerleşen, sigortalı bir işte
çalışanların oranıdır. Kazanç grubu bölümler arası kıyastır, maaş
değildir.
</p>
@@ -409,10 +404,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
];
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
// çekmiyor): öğrencinin sorusuna cevap veren dört sayı — en seçici
// program, devlette en geniş kapı, orta tabanın yıllar içindeki seyri ve
// boş kalan koltuk. Hesap lib/taban-ozeti.ts; program/devlet/vakıf sayıları
// özet paragrafta kalır. Üniversite sayfasıyla aynı düzen (DESIGN.md §4.8).
// çekmiyor): öğrencinin sorusuna cevap veren üç sayı — en yüksek taban,
// devlette en düşük taban ve boş kalan kontenjan. Orta taban kartı
// kaldırıldı (Bilal, 6 Eki 2026). Hesap lib/taban-ozeti.ts; program/
// devlet/vakıf sayıları özet paragrafta kalır. Üniversite sayfasıyla aynı
// kartlar (DESIGN.md §4.8).
const { taban } = bolum;
const uniBaglantisi = (universite: string) => (
<KartBaglantisi
@@ -554,13 +550,14 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
</div>
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin
sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8); burada üç.
Telefonda iki sütunda tek kalan üçüncü kart satırı boylu boyunca
alır. Derin sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
{ilkSayfa ? (
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-3 [&>*:last-child]:col-span-2 lg:[&>*:last-child]:col-span-1">
<OzetKart
ikon={Target}
etiket="En seçici"
etiket="En yüksek taban"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
@@ -575,11 +572,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
)
}
/>
{/* Etiket kısa kalır ("Devlette en geniş" 131 px'te iki satıra
{/* Etiket kısa kalır ("Devlette en düşük" 131 px'te iki satıra
kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
<OzetKart
ikon={Landmark}
etiket="En geniş kapı"
etiket="En düşük taban"
{...(taban.enGenis
? kartSayisi(taban.enGenis.sira)
: { deger: "—" })}
@@ -595,18 +592,9 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
)
}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban].reverse().find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
})()}
alt={ortaTabanAciklamasi(taban)}
/>
<OzetKart
ikon={Armchair}
etiket="Boş koltuk"
etiket="Boş kontenjan"
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
alt={
taban.doluluk

View File

@@ -37,13 +37,14 @@ import { UNITUR_ETIKET } from "@/lib/unitur";
import { cn } from "@/lib/utils";
import { SON_YIL } from "@/lib/veri-yillari";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
AltSayfa,
AltSayfaKapat,
AltSayfaGovde,
AltSayfaIcerik,
AltSayfaAciklama,
AltSayfaBaslik,
AltSayfaTetik,
} from "@/components/ui/alt-sayfa";
import {
Table,
TableBody,
@@ -137,8 +138,8 @@ export function ProgramAyrintiButonu({
}
return (
<Drawer onOpenChange={acildi} handleOnly>
<DrawerTrigger asChild>
<AltSayfa onOpenChange={acildi}>
<AltSayfaTetik asChild>
{varyant === "metin" ? (
<button
type="button"
@@ -160,21 +161,24 @@ export function ProgramAyrintiButonu({
<span className="sr-only">{isim} — program ayrıntısı</span>
</button>
)}
</DrawerTrigger>
{/* Çekmece kabı ve çarpı cta-sira-form.tsx'teki çekmeceyle birebir (§4.10, §4.3) */}
<DrawerContent
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
</AltSayfaTetik>
{/* Alt sayfa kabı tek kaynak (alt-sayfa.tsx), çarpı §4.3. İçerik uzun:
sayfa ekranın %60'ında açılır (başlık + künye), yukarı çekince ya da
kaydırınca tam boya çıkar (§4.19). */}
<AltSayfaIcerik
duraklar={[0.6, 0.92]}
ilkDurak={0}
onClick={(e) => e.stopPropagation()}
>
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
</AltSayfaKapat>
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{isim}
</DrawerTitle>
<DrawerDescription className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
</AltSayfaBaslik>
<AltSayfaAciklama className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
{veri
? [
trBaslikDuzeni(veri.program.universite),
@@ -184,13 +188,13 @@ export function ProgramAyrintiButonu({
.filter(Boolean)
.join(" · ")
: (universite ?? "Programın tüm açık verisi")}
</DrawerDescription>
</AltSayfaAciklama>
<div className="mt-5">
<AyrintiIcerik durum={durum} veri={veri} />
</div>
</div>
</DrawerContent>
</Drawer>
</AltSayfaGovde>
</AltSayfaIcerik>
</AltSayfa>
);
}

View File

@@ -1,47 +1,14 @@
import Link from "next/link";
import type { ReactNode } from "react";
import {
ortaTabanYonu,
YON_ETIKET,
type TabanKumesi,
type TabanDuzeyi,
type TabanOzeti,
} from "@/lib/taban-ozeti";
import { kisaSayi } from "@/components/ozet-kart";
/**
* Üniversite ve bölüm sayfasının özet kartlarının ortak metin ve link
* yardımcıları (DESIGN.md §4.8). Yıl yıl ayrıntı çekmecesi ("Yıllara göre
* taban") 6 Eki 2026'da Bilal'in isteğiyle şimdilik kaldırıldı; geri gelirse
* git geçmişinde `taban-yillari.tsx` + `siranla-kapilar.tsx` olarak duruyor.
* taban") ve orta taban kartı 6 Eki 2026'da Bilal'in isteğiyle kaldırıldı;
* geri gelirse git geçmişinde `taban-yillari.tsx`, `siranla-kapilar.tsx` ve
* `ortaTabanAciklamasi` olarak duruyor.
*/
/** "2026'da" — yıl eki ünlü uyumuna göre (2023'te, 2026'da, 2027'de). */
export function yilEki(yil: number): string {
const son = yil % 10;
if (son === 3 || son === 4 || son === 5) return `${yil}'te`;
if (son === 0 || son === 6 || son === 9) return `${yil}'da`;
return `${yil}'de`;
}
/** Orta taban kümesinin adı: "Devlet" / "Lisans" / "Önlisans". */
export function kumeAdi(kume: TabanKumesi, duzey: TabanDuzeyi): string {
if (kume === "devlet") return "Devlet";
return duzey === "lisans" ? "Lisans" : "Önlisans";
}
/** Özet kartının açıklaması: "Devlet · 2021'de 105 bin · gevşedi". */
export function ortaTabanAciklamasi(taban: TabanOzeti): string {
const yon = ortaTabanYonu(taban.ortaTaban);
const kume = kumeAdi(taban.kume, taban.duzey);
if (!yon) {
return taban.ortaTabanAdet > 0
? `${kume} · ${taban.ortaTabanAdet.toLocaleString("tr-TR")} programın ortası`
: "henüz taban oluşmadı";
}
return `${kume} · ${yilEki(yon.ilkYil)} ${kisaSayi(yon.ilk)} · ${YON_ETIKET[yon.yon]}`;
}
/** Özet kartı açıklamasındaki ad linki (üniversite ↔ bölüm çapraz bağ). */
export function KartBaglantisi({
href,

View File

@@ -5,7 +5,6 @@ import {
Armchair,
ArrowRight,
Building2,
ChartLine,
ChevronRight,
Landmark,
LayoutList,
@@ -57,7 +56,7 @@ import { UrapLogo } from "./urap-logo";
import { AkademikAyrinti } from "./akademik-ayrinti";
import { NavBaslik } from "./nav-baslik";
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
import { KartBaglantisi, ortaTabanAciklamasi } from "./taban-karti";
import { KartBaglantisi } from "./taban-karti";
import { bolumBazAdi } from "@/lib/slug";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
@@ -678,14 +677,15 @@ export function UniversiteIcerik({
{/* Şablon tam 4 hücre bırakıyor: masaüstünde harita 3. sütunu
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Dört
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
program/kontenjan adedi ilgi çekmiyor): en seçici bölüm
adıyla, orta tabanın yıllar içindeki seyri, doluluk ve
Türkiye sırası. Program, birim ve kontenjan sayıları özet
paragrafta. Hesap lib/taban-ozeti.ts. */}
program/kontenjan adedi ilgi çekmiyor): en yüksek ve en
düşük taban bölüm adıyla, doluluk ve Türkiye sırası. Orta
taban kartı kaldırıldı (Bilal, 6 Eki 2026). Program, birim
ve kontenjan sayıları özet paragrafta. Hesap
lib/taban-ozeti.ts. */}
<dl className="contents">
<OzetKart
ikon={Target}
etiket="En seçici bölüm"
etiket="En yüksek taban"
{...(taban.enSecici
? kartSayisi(taban.enSecici.sira)
: { deger: "—" })}
@@ -708,15 +708,28 @@ export function UniversiteIcerik({
}
/>
<OzetKart
ikon={ChartLine}
etiket="Orta taban"
{...(() => {
const son = [...taban.ortaTaban]
.reverse()
.find((v) => v != null);
return son != null ? kartSayisi(son) : { deger: "—" };
})()}
alt={ortaTabanAciklamasi(taban)}
ikon={Landmark}
etiket="En düşük taban"
{...(taban.enGenis
? kartSayisi(taban.enGenis.sira)
: { deger: "—" })}
alt={
taban.enGenis ? (
<>
<KartBaglantisi
href={(() => {
const s = bolumSlugFromIsim(taban.enGenis.isim);
return s ? `/bolum/${s}` : null;
})()}
>
{bolumBazAdi(taban.enGenis.isim)}
</KartBaglantisi>{" "}
· {SON_YIL} taban sırası
</>
) : (
"henüz taban oluşmadı"
)
}
/>
{taban.doluluk ? (
<OzetKart
@@ -725,7 +738,7 @@ export function UniversiteIcerik({
deger={`%${taban.doluluk.yuzde}`}
alt={
taban.doluluk.bosKoltuk > 0
? `${sayi(taban.doluluk.kontenjan)} kontenjandan ${sayi(taban.doluluk.bosKoltuk)} koltuk boş kaldı · ${SON_YIL}`
? `${sayi(taban.doluluk.kontenjan)} kontenjanın ${sayi(taban.doluluk.bosKoltuk)} tanesi boş kaldı · ${SON_YIL}`
: `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
}
/>

View File

@@ -3,7 +3,7 @@
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, MailCheck } from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { authClient } from "@/lib/auth-client";
import { olay } from "@/lib/analitik";
import { Button } from "@/components/ui/button";

View File

@@ -1,18 +1,18 @@
"use client";
// Manuel 24'lük listenin alt çekmecesi (bottom drawer). Layout'ta bir kez
// Manuel 24'lük listenin alt sayfası (DESIGN.md §4.10). Layout'ta bir kez
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
//
// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır
// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması
// da kapatır.
// Alt sayfa (alt-sayfa.tsx): tutamaçtan ya da başlıktan aşağı çekince, liste
// en üstteyken aşağı çekince kapanır; Escape ve perde tıklaması da kapatır.
//
// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları
// sadeleşir; X butonu her durumda kalır.
// Satır sıralama yalnız grip tutamağından başlar (dragControls). Satırı sola
// kaydırmak "Çıkar" eylemini açar, sonuna kadar çekmek uygular
// (kaydirma-eylemi.tsx, §4.23); aynı iş satırdaki "−" düğmesiyle de yapılır.
// prefers-reduced-motion: satır animasyonları sadeleşir.
import { useState } from "react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SayiRulo } from "@/components/ui/sayi-rulo";
@@ -21,13 +21,14 @@ import { UniLogo } from "@/components/uni-logo";
import { RiskEtiketi } from "@/components/risk-etiketi";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
AltSayfa,
AltSayfaBaslik,
AltSayfaGovde,
AltSayfaIcerik,
AltSayfaKapat,
AltSayfaSurukle,
} from "@/components/ui/alt-sayfa";
import { KaydirmaGrubu, KaydirmaSatiri } from "@/components/ui/kaydirma-eylemi";
import {
ArrowRight,
Check,
@@ -43,9 +44,7 @@ import {
motion,
Reorder,
useDragControls,
useMotionValue,
useTransform,
type PanInfo,
useReducedMotion,
} from "motion/react";
import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri";
@@ -120,7 +119,7 @@ export function ListeCekmecesi() {
const girisli = useOturumDurumu();
const girisUrl = useGirisUrl();
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
// drawer içinde erken ölçüm satırları kaydırıyordu
// sayfa içinde erken ölçüm satırları kaydırıyordu
const [oturdu, setOturdu] = useState(false);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
@@ -144,7 +143,7 @@ export function ListeCekmecesi() {
}
return (
<Drawer
<AltSayfa
open={acik}
onOpenChange={(v) => {
if (v) cekmeceAc();
@@ -153,39 +152,35 @@ export function ListeCekmecesi() {
cekmeceKapat();
}
}}
handleOnly
onAnimationEnd={(open) => setOturdu(open)}
>
<DrawerContent
className="min-h-0 max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
<AltSayfaIcerik onOturdu={setOturdu} aria-describedby={undefined}>
{/* Alt sayfa başlığı (DESIGN.md §4.10): ortalı başlık + bağlam;
çarpı §4.3 kanonik sınıf setinde, temizle düğmesi aynı ölçüde
solda. */}
<DrawerHeader className="relative border-b border-slate-100 px-4 pt-5 pb-4 text-center md:text-center">
{liste.length > 0 ? (
<button
type="button"
onClick={() =>
geriAlinabilirCikar(
liste.map((t) => t.id),
`Listen temizlendi · ${liste.length} program çıkarıldı`,
)
}
title="Listeyi temizle"
className="absolute top-3 left-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
solda. Başlık alanından da tutulup aşağı çekilir. */}
{liste.length > 0 ? (
<button
type="button"
onClick={() =>
geriAlinabilirCikar(
liste.map((t) => t.id),
`Listen temizlendi · ${liste.length} program çıkarıldı`,
)
}
title="Listeyi temizle"
className="absolute top-3 left-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</AltSayfaKapat>
<AltSayfaSurukle className="border-b border-slate-100 px-4 pt-3 pb-4 text-center">
<AltSayfaBaslik className="px-10 text-center">
Kendi Listem
</DrawerTitle>
</AltSayfaBaslik>
{/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3). Bilal'in
kararı (24 Eyl 2026): buradaki piksel liste başlığı çok dolu
gösteriyordu; ilerleme çubuğuna geri dönüldü. */}
@@ -238,11 +233,9 @@ export function ListeCekmecesi() {
Güvenli yok; alta birkaç tane eklemek riski azaltır.
</p>
) : null}
</DrawerHeader>
</AltSayfaSurukle>
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-2"
>
<AltSayfaGovde className="overflow-x-hidden px-4 py-2">
{liste.length === 0 ? (
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
<ListChecks
@@ -258,24 +251,26 @@ export function ListeCekmecesi() {
</p>
</div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layoutScroll
as="ol"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))}
</AnimatePresence>
</Reorder.Group>
<KaydirmaGrubu>
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layoutScroll
as="ol"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))}
</AnimatePresence>
</Reorder.Group>
</KaydirmaGrubu>
)}
{liste.length < MANUEL_LISTE_MAX ? (
@@ -290,9 +285,9 @@ export function ListeCekmecesi() {
Program ekle
</button>
) : null}
</div>
</AltSayfaGovde>
<DrawerFooter className="border-t border-slate-100 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
<div className="mt-auto flex flex-col gap-2 border-t border-slate-100 px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
{girisli === false ? (
@@ -373,15 +368,12 @@ export function ListeCekmecesi() {
</Button>
)}
</CtaBandi>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
</AltSayfaIcerik>
</AltSayfa>
);
}
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
const SIL_ESIK = 90;
function CekmeceSatiri({
tercih,
sira,
@@ -392,19 +384,9 @@ function CekmeceSatiri({
projeksiyonAcik: boolean;
}) {
const siraKontrol = useDragControls();
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
const x = useMotionValue(0);
const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
const kaydirinca = (_: unknown, info: PanInfo) => {
if (
info.offset.x > SIL_ESIK ||
(info.offset.x > 32 && info.velocity.x > 500)
) {
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
}
};
const azalt = useReducedMotion();
const cikarIste = () =>
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
return (
<Reorder.Item
@@ -414,36 +396,34 @@ function CekmeceSatiri({
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
dragListener={false}
dragControls={siraKontrol}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
initial={azalt ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
// Satır "Çıkar" bandının altında yana kayarak gider (KaydirmaSatiri);
// burada yalnız söner, alttakiler layout animasyonuyla yukarı gelir.
exit={{ opacity: 0, transition: { duration: 0.16, delay: azalt ? 0 : 0.14 } }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{ scale: 1.03, zIndex: 10 }}
className="relative select-none border-b border-slate-100 last:border-b-0"
className="relative border-b border-slate-100 last:border-b-0"
>
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
<motion.div
style={{ opacity: silGorunur }}
className="absolute inset-0 flex items-center bg-red-500 pl-4"
aria-hidden
>
<Trash2 className="size-4 text-white" />
</motion.div>
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
<motion.div
drag="x"
style={{ x }}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0, right: 0.7 }}
dragSnapToOrigin
onDragEnd={kaydirinca}
className="relative flex items-center gap-3 bg-card py-3"
{/* Sola kaydır: "Çıkar" açılır; sonuna kadar çekince uygulanır (§4.23) */}
<KaydirmaSatiri
sag={[
{
etiket: "Çıkar",
ikon: <CircleMinus aria-hidden />,
ton: "tehlike",
onSec: cikarIste,
},
]}
icerikSinifi="flex items-center gap-3 bg-card py-3"
>
<button
type="button"
// Dikey sürükleme sıralamaya aittir: ne yatay kaydırmayı ne de alt
// sayfanın aşağı çekilişini başlatır.
data-alt-sayfa-yoksay=""
onPointerDown={(e) => {
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
e.stopPropagation();
siraKontrol.start(e);
}}
className="-ml-1 flex h-11 w-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-slate-300 transition-colors hover:text-slate-500 active:cursor-grabbing"
@@ -458,7 +438,6 @@ function CekmeceSatiri({
<Link
href={bolumHref(tercih.isim)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className={SATIR_AD}
>
{tercih.isim}
@@ -468,7 +447,6 @@ function CekmeceSatiri({
<Link
href={uniHref(tercih.universite)}
onClick={cekmeceKapat}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
title={`${tercih.universite} taban puanları sayfası`}
className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
>
@@ -491,18 +469,16 @@ function CekmeceSatiri({
</span>
<button
type="button"
onClick={() =>
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
onClick={cikarIste}
// Satırdan çıkarma "−" ile, çerçevesiz: kapatma çarpısıyla (§4.3)
// karışmasın diye çarpı yalnız çekmecenin kendisini kapatır.
// karışmasın diye çarpı yalnız sayfanın kendisini kapatır. Kaydırma
// eyleminin klavye ve ekran okuyucu yolu da bu düğmedir.
className="inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-[background-color,color,transform] duration-150 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92]"
>
<CircleMinus className="size-5" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span>
</button>
</motion.div>
</KaydirmaSatiri>
</Reorder.Item>
);
}

View File

@@ -2,7 +2,7 @@
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { ArrowRight, Check, Plus } from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight, Trophy } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View File

@@ -3,7 +3,7 @@
import { useLayoutEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { Button } from "@/components/ui/button";
import { olay } from "@/lib/analitik";
import { baslatOdeme } from "../odeme-actions";

View File

@@ -0,0 +1,109 @@
"use client";
// Ana sayfadaki "Dene" bölümü (DESIGN.md §4.27): örnek bir sıralamaya göre
// gerçek programlar kart destesinde tek tek sorulur — "listene girer mi?".
// Ürünün yaptığı işin (24 tercihi riskine göre seçmek) elle bir tadımlığı.
// Veri sunucudan gelir (landing-verisi.ts); burada uydurma sayı yoktur.
import { useState } from "react";
import Link from "next/link";
import { ArrowRight, Landmark, MapPin } from "lucide-react";
import { Button } from "@/components/ui/button";
import { KartDestesi } from "@/components/ui/kart-destesi";
import { RiskEtiketi } from "@/components/risk-etiketi";
import type { OrnekProgram } from "@/features/pazarlama/landing-verisi";
function ProgramKarti({ program, ornekSira }: { program: OrnekProgram; ornekSira: string }) {
return (
<div className="flex h-full flex-col">
{/* Künye şeridi: sürüklerken karar damgaları bu şeridin üstünde belirir */}
<div className="flex h-16 shrink-0 items-center justify-end gap-3 border-b border-slate-100 bg-slate-50 px-5 text-sm font-semibold text-slate-500">
{program.il ? (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-4 text-primary" aria-hidden />
{program.il}
</span>
) : null}
<span className="inline-flex items-center gap-1.5">
<Landmark className="size-4 text-primary" aria-hidden />
Devlet
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col p-5 text-left">
<p className="line-clamp-2 font-heading text-xl font-bold leading-snug text-slate-900">
{program.isim}
</p>
<p className="mt-1 line-clamp-1 text-sm text-slate-500">{program.universite}</p>
<div className="mt-auto">
<p className="text-sm font-semibold text-slate-500">Son taban sıralaması</p>
<p className="mt-1 font-heading text-4xl font-bold leading-none tracking-tight text-slate-900">
{program.tabanSira.toLocaleString("tr-TR")}
</p>
<p className="mt-3 flex items-center gap-2 text-sm text-slate-500">
<RiskEtiketi risk={program.risk} />
<span aria-hidden>·</span>
senin sıran {ornekSira}
</p>
</div>
</div>
</div>
);
}
export function DesteDemosu({
programlar,
ornekSira,
}: {
programlar: OrnekProgram[];
/** Biçimlenmiş örnek sıralama ("85.000"). */
ornekSira: string;
}) {
const [alinanlar, setAlinanlar] = useState<string[]>([]);
return (
<KartDestesi
ogeler={programlar}
anahtar={(p) => p.id}
ad={(p) => `${p.isim}, ${p.universite}`}
etiket="Örnek programlar"
etiketler={{ sol: "Geç", sag: "Listeme al" }}
onKarar={(p, karar) => {
if (karar === "sag") setAlinanlar((a) => [...a, p.id]);
}}
onGeriAl={(p, karar) => {
if (karar === "sag") setAlinanlar((a) => a.filter((id) => id !== p.id));
}}
onSifirla={() => setAlinanlar([])}
kart={(p) => <ProgramKarti program={p} ornekSira={ornekSira} />}
bos={(sifirla) => (
<>
<p className="font-heading text-xl font-bold text-balance text-slate-900">
{alinanlar.length > 0
? `${alinanlar.length} programı listene aldın`
: "Hiçbirini almadın, olur"}
</p>
<p className="max-w-56 text-sm leading-relaxed text-slate-600">
Gerçek listede bunu 24 tercih için biz yaparız; her birinin gerekçesini de yazarız.
</p>
<Button
asChild
size="lg"
className="mt-1 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
<Link href="#hero-form">
Sıralamamı gir
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<button
type="button"
onClick={sifirla}
className="min-h-11 cursor-pointer text-sm font-semibold text-slate-500 transition-colors duration-150 hover:text-orange-500"
>
Kartları baştan gör
</button>
</>
)}
/>
);
}

View File

@@ -0,0 +1,245 @@
"use client";
// Program duvarı (DESIGN.md §4.29): "24 tercih, binlerce ihtimal" cümlesinin
// resmi. Küçük bir pencerede piksel kareler satır satır akar — her kare bir
// program; aralarında tam 24 turuncu kare yerinde durur: senin listen.
// İmleç yaklaştığı satırları hızlandırır. Veri taşıyan pikseldir (§1.2);
// anlamı altındaki cümle söyler, renk tek başına değil (§2.2).
//
// Esin: React Bits "Squares Terminal" (yanıp sönen kare ızgarası, imlece
// tepki veren satırlar). Kod bize ait: koyu CRT ekranı, parlama ve tüp eğimi
// alınmadı; açık zemin, hero'nun ızgara ölçüleri (hücre 10, nokta 7.4, köşe 2)
// ve doku opaklıkları kullanılır. Tek <canvas>, yalnız ekrandayken ~30 fps;
// hareketi azalt'ta durağan tek kare.
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
const HUCRE = 10;
const NOKTA = 7.4;
const BOSLUK = (HUCRE - NOKTA) / 2;
// Hero ile aynı doku paleti
const OPAKLIKLAR = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const TERCIH = 24;
/** İmlecin satırları etkilediği dikey uzaklık (px) ve en çok ek hız (hücre/sn). */
const ETKI = 46;
const EK_HIZ = 9;
// Deterministik sözde-rastgele: sunucuda yok ama her ziyarette aynı desen
const karma = (x: number, y: number) => {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return n - Math.floor(n);
};
export function ProgramDuvari({
etiket,
className,
}: {
/** Pencere çubuğundaki kısa yazı, ör. "21.000+ program". */
etiket: string;
className?: string;
}) {
const kapRef = useRef<HTMLDivElement>(null);
const tuvalRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const kap = kapRef.current;
const tuval = tuvalRef.current;
const ctx = tuval?.getContext("2d");
if (!kap || !tuval || !ctx) return;
const azalt = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let en = 0;
let boy = 0;
let sutun = 0;
let satir = 0;
let mavi = "";
let turuncu = "";
/** Satır başına kayma (hücre) ve imleç kaynaklı ek hız. */
let kayma: number[] = [];
let ekHiz: number[] = [];
let tercihler: { c: number; r: number }[] = [];
let imlecY: number | null = null;
let gorunur = false;
let kare = 0;
let onceki = 0;
let basladi = 0;
const renkleriOku = () => {
// Tema değişince palet token'ları değişir (§2.3); renk CSS'ten okunur
mavi = getComputedStyle(tuval).color;
turuncu =
getComputedStyle(document.documentElement).getPropertyValue("--color-orange-500").trim() ||
"#f97316";
};
const olc = () => {
const kutu = kap.getBoundingClientRect();
const oran = Math.min(window.devicePixelRatio || 1, 2);
en = Math.round(kutu.width);
boy = Math.round(kutu.height);
tuval.width = en * oran;
tuval.height = boy * oran;
ctx.setTransform(oran, 0, 0, oran, 0, 0);
sutun = Math.ceil(en / HUCRE) + 1;
satir = Math.floor(boy / HUCRE);
kayma = Array.from({ length: satir }, (_, r) => kayma[r] ?? karma(r, 3) * 40);
ekHiz = Array.from({ length: satir }, () => 0);
// 24 tercih: kenarlardan bir hücre içeride, birbirine değmeyen sabit kareler
tercihler = [];
const dolu = new Set<string>();
for (let i = 0; tercihler.length < TERCIH && i < 4000; i++) {
const c = 1 + Math.floor(karma(i, 17) * Math.max(1, sutun - 3));
const r = 1 + Math.floor(karma(i, 29) * Math.max(1, satir - 2));
const yakin = [-1, 0, 1].some((dx) =>
[-1, 0, 1].some((dy) => dolu.has(`${c + dx}:${r + dy}`)),
);
if (yakin) continue;
dolu.add(`${c}:${r}`);
tercihler.push({ c, r });
}
};
const kareCiz = (x: number, y: number) => {
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, NOKTA, NOKTA, 2);
else ctx.rect(x, y, NOKTA, NOKTA);
ctx.fill();
};
const ciz = (t: number) => {
ctx.clearRect(0, 0, en, boy);
const ustBosluk = (boy - satir * HUCRE) / 2;
ctx.fillStyle = mavi;
for (let r = 0; r < satir; r++) {
const tam = Math.floor(kayma[r]);
const kesir = kayma[r] - tam;
// Tek satırlar sola, çift satırlar sağa akar
const yon = r % 2 === 0 ? 1 : -1;
for (let c = -1; c < sutun; c++) {
const kimlik = c - yon * tam;
const h = karma(kimlik, r);
// Karelerin üçte biri boş: duvar değil doku
if (h < 0.34) continue;
const taban = OPAKLIKLAR[Math.abs(kimlik * 7 + r * 11) % OPAKLIKLAR.length];
// Yavaş nefes: her kare kendi fazında hafifçe parlar ve söner
const nefes = 0.6 + 0.4 * Math.sin(t * 0.9 + h * 6.283);
ctx.globalAlpha = taban * nefes;
kareCiz((c + yon * kesir) * HUCRE + BOSLUK, ustBosluk + r * HUCRE + BOSLUK);
}
}
// Tercihler: sırayla belirir (110 ms arayla), sonra yerinde kalır
ctx.fillStyle = turuncu;
const gecen = azalt ? Infinity : t - basladi;
tercihler.forEach(({ c, r }, i) => {
const yas = gecen - 0.4 - i * 0.11;
if (yas <= 0) return;
const giris = Math.min(1, yas / 0.3);
const x = c * HUCRE + BOSLUK;
const y = ustBosluk + r * HUCRE + BOSLUK;
// Altındaki akan kareyi sil: turuncu kare tek başına okunur
ctx.globalAlpha = 1;
ctx.clearRect(c * HUCRE, ustBosluk + r * HUCRE, HUCRE, HUCRE);
ctx.globalAlpha = 0.9 * giris;
kareCiz(x, y);
});
ctx.globalAlpha = 1;
};
const adim = (simdi: number) => {
kare = requestAnimationFrame(adim);
// ~30 fps yeter: kareler yavaş akar
if (simdi - onceki < 32) return;
const dt = Math.min(0.1, (simdi - onceki) / 1000);
onceki = simdi;
const ustBosluk = (boy - satir * HUCRE) / 2;
for (let r = 0; r < satir; r++) {
const merkez = ustBosluk + r * HUCRE + HUCRE / 2;
const yakinlik =
imlecY == null ? 0 : Math.max(0, 1 - Math.abs(merkez - imlecY) / ETKI);
// Ek hız hedefe yumuşak yaklaşır; imleç çekilince yavaşça söner
ekHiz[r] += (yakinlik * yakinlik * EK_HIZ - ekHiz[r]) * Math.min(1, dt * 5);
kayma[r] += (0.5 + karma(r, 7) * 1.3 + ekHiz[r]) * dt;
}
ciz(simdi / 1000);
};
const baslat = () => {
if (kare || azalt || !gorunur) return;
onceki = performance.now();
if (!basladi) basladi = onceki / 1000;
kare = requestAnimationFrame(adim);
};
const durdur = () => {
cancelAnimationFrame(kare);
kare = 0;
};
renkleriOku();
olc();
ciz(0);
const boyut = new ResizeObserver(() => {
olc();
ciz(performance.now() / 1000);
});
boyut.observe(kap);
const gorunum = new IntersectionObserver(([kayit]) => {
gorunur = kayit.isIntersecting;
if (gorunur) baslat();
else durdur();
});
gorunum.observe(kap);
const tema = new MutationObserver(() => {
renkleriOku();
ciz(performance.now() / 1000);
});
tema.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const imlec = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const kutu = kap.getBoundingClientRect();
const icinde =
e.clientX >= kutu.left &&
e.clientX <= kutu.right &&
e.clientY >= kutu.top - ETKI &&
e.clientY <= kutu.bottom + ETKI;
imlecY = icinde ? e.clientY - kutu.top : null;
};
window.addEventListener("pointermove", imlec, { passive: true });
return () => {
durdur();
boyut.disconnect();
gorunum.disconnect();
tema.disconnect();
window.removeEventListener("pointermove", imlec);
};
}, []);
return (
<figure className={cn("mx-auto w-full max-w-3xl", className)}>
{/* Mini pencere: aramadaki tarayıcı penceresiyle aynı dil (§4.20) */}
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
<div className="flex h-9 items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-3.5">
<span className="size-2 rounded-full bg-slate-300" aria-hidden />
<span className="size-2 rounded-full bg-slate-300 opacity-65" aria-hidden />
<span className="size-2 rounded-full bg-slate-300 opacity-35" aria-hidden />
<span className="ml-2 text-xs font-medium text-slate-500">{etiket}</span>
</div>
<div ref={kapRef} className="h-36 sm:h-44">
<canvas
ref={tuvalRef}
role="img"
aria-label="Akan piksel kareler: her kare bir program. Aralarında 24 turuncu kare senin tercih listen."
className="block size-full text-primary"
/>
</div>
</div>
<figcaption className="mt-3 flex items-center justify-center gap-2 text-center text-sm text-slate-500">
<span className="size-2.5 shrink-0 rounded-[3px] bg-orange-500" aria-hidden />
Her kare bir program; turuncular senin 24 tercihin.
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,208 @@
"use client";
// Süzülen haplar (DESIGN.md §4.30): kataloğun en çok aranan bölüm ve şehir
// sayfalarına giden haplar. Bölüm ekrana girince haplar yukarıdan hafif
// eğik düşüp yerine oturur; imleç gezdikçe her hap kendi derinliğinde azıcık
// süzülür, arkadaki piksel kareler ters yöne kayar. Her hap gerçek bir
// linktir (§4.12 kimlik hapı, hover tek turuncu).
//
// Esin: React Bits "Parallax Pills" (imleci izleyen, sekerek giren etiketli
// haplar). Kod bize ait: haplar mutlak konumla değil akışla dizilir (375 px'te
// üst üste binmez), giriş tek ve hafif sekmeli (düşen nesne, §6), arka plan
// süs hapı yerine sitenin piksel kareleri. Dokunmatikte süzülme yoktur;
// hareketi azalt'ta haplar yalnız belirir.
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import { GraduationCap, MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
import type { KatalogHapi } from "@/features/pazarlama/landing-verisi";
/** İmlecin kenardan kenara taşıdığı en büyük kayma (px). */
const GUC = 18;
const SUZULME_YAYI = { stiffness: 120, damping: 20, mass: 0.6 };
/** Düşüş: yukarıdan gelir, tek kez hafif seker. */
const DUSUS = { type: "spring", duration: 0.7, bounce: 0.22 } as const;
const karma = (i: number, tohum: number) => {
const n = Math.sin(i * 127.1 + tohum * 311.7) * 43758.5453;
return n - Math.floor(n);
};
// Arkadaki piksel kareler: kenarlarda, orta boş (§10.8 doku kuralı). Konum %.
const KARELER = [
{ x: 4, y: 12, boy: 27, sicak: false },
{ x: 13, y: 78, boy: 17, sicak: true },
{ x: 24, y: 2, boy: 17, sicak: false },
{ x: 77, y: 0, boy: 27, sicak: false },
{ x: 90, y: 22, boy: 17, sicak: true },
{ x: 94, y: 70, boy: 37, sicak: false },
{ x: 70, y: 90, boy: 17, sicak: false },
{ x: 0, y: 52, boy: 17, sicak: false },
];
type Durum = "ssr" | "bekliyor" | "geldi";
function Hap({
hap,
sira,
durum,
azalt,
mx,
my,
}: {
hap: KatalogHapi;
sira: number;
durum: Durum;
azalt: boolean;
mx: MotionValue<number>;
my: MotionValue<number>;
}) {
// Her hap kendi derinliğinde: öndekiler çok, arkadakiler az kayar
const derinlik = 0.45 + karma(sira, 5) * 0.75;
const x = useTransform(mx, (v) => v * GUC * derinlik);
const y = useTransform(my, (v) => v * GUC * 0.7 * derinlik);
const egim = (karma(sira, 9) - 0.5) * 16;
const iri = sira % 3 === 0;
const Ikon = hap.tur === "sehir" ? MapPin : GraduationCap;
const gizli = azalt ? { opacity: 0 } : { opacity: 0, y: -36, rotate: egim };
return (
<motion.div style={{ x, y }}>
<motion.div
initial={false}
animate={durum === "bekliyor" ? gizli : { opacity: 1, y: 0, rotate: 0 }}
transition={
durum !== "geldi"
? { duration: 0 }
: azalt
? { duration: 0.2, delay: sira * 0.02 }
: { ...DUSUS, delay: sira * 0.045, opacity: { duration: 0.2, delay: sira * 0.045 } }
}
>
<Link
href={hap.href}
className={cn(
"inline-flex items-center gap-2 rounded-full border border-slate-200 bg-card font-semibold text-slate-800 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]",
iri ? "h-12 px-5 text-base" : "h-11 px-4 text-sm",
)}
>
<Ikon className="size-4 shrink-0 text-primary" aria-hidden />
{hap.etiket}
<span className="sr-only">
{hap.tur === "sehir" ? " üniversiteleri" : " taban puanları"}
</span>
</Link>
</motion.div>
</motion.div>
);
}
function ArkaKare({
kare,
mx,
my,
}: {
kare: (typeof KARELER)[number];
mx: MotionValue<number>;
my: MotionValue<number>;
}) {
// Arka katman ters yöne ve daha çok kayar: derinlik hissi
const x = useTransform(mx, (v) => -v * GUC * 1.4);
const y = useTransform(my, (v) => -v * GUC);
return (
<motion.span
className={cn(
"absolute rounded",
kare.sicak ? "bg-orange-500/70" : "bg-primary/15",
)}
style={{ left: `${kare.x}%`, top: `${kare.y}%`, width: kare.boy, height: kare.boy, x, y }}
/>
);
}
export function SuzulenHaplar({
haplar,
className,
}: {
haplar: KatalogHapi[];
className?: string;
}) {
const kapRef = useRef<HTMLDivElement>(null);
const azalt = useReducedMotion() ?? false;
// SSR'da haplar görünür çizilir (JS'siz ve bot için). Montajdan sonra bölüm
// henüz ekranda değilse gizlenir, girince düşer; zaten ekrandaysa dokunulmaz.
const [durum, setDurum] = useState<Durum>("ssr");
const hamX = useMotionValue(0);
const hamY = useMotionValue(0);
const mx = useSpring(hamX, SUZULME_YAYI);
const my = useSpring(hamY, SUZULME_YAYI);
useEffect(() => {
const kap = kapRef.current;
if (!kap || typeof IntersectionObserver === "undefined") return;
let ilk = true;
const io = new IntersectionObserver(
([kayit]) => {
if (kayit.isIntersecting) {
setDurum((d) => (d === "bekliyor" ? "geldi" : d));
io.disconnect();
} else if (ilk) {
setDurum("bekliyor");
}
ilk = false;
},
{ rootMargin: "0px 0px -80px 0px" },
);
io.observe(kap);
return () => io.disconnect();
}, []);
// İmleç: yalnız fare; kabın ortasına göre −0,5…0,5
useEffect(() => {
const kap = kapRef.current;
if (!kap || azalt || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
return;
}
const oynadi = (e: PointerEvent) => {
const kutu = kap.getBoundingClientRect();
hamX.set(Math.max(-0.5, Math.min(0.5, (e.clientX - kutu.left) / kutu.width - 0.5)));
hamY.set(Math.max(-0.5, Math.min(0.5, (e.clientY - kutu.top) / kutu.height - 0.5)));
};
const cikti = () => {
hamX.set(0);
hamY.set(0);
};
kap.addEventListener("pointermove", oynadi, { passive: true });
kap.addEventListener("pointerleave", cikti);
return () => {
kap.removeEventListener("pointermove", oynadi);
kap.removeEventListener("pointerleave", cikti);
};
}, [azalt, hamX, hamY]);
return (
<div ref={kapRef} className={cn("relative isolate px-2 py-10 sm:px-10", className)}>
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
{KARELER.map((kare) => (
<ArkaKare key={`${kare.x}-${kare.y}`} kare={kare} mx={mx} my={my} />
))}
</div>
<ul className="mx-auto flex max-w-2xl flex-wrap items-center justify-center gap-x-2.5 gap-y-3">
{haplar.map((hap, i) => (
<li key={hap.href}>
<Hap hap={hap} sira={i} durum={durum} azalt={azalt} mx={mx} my={my} />
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
// Veri akışı (DESIGN.md §4.28): listenin neyle kurulduğunu tek bakışta
// gösterir. Ortada KolayTercih işareti, çevresinde altı girdi; her girdiden
// merkeze düz bir çizgi iner, üzerinde piksel kare akar. Soldaki üçü
// öğrencinin seçimi (turuncu), sağdaki üçü YÖK Atlas verisi (mavi) — ayrımı
// renk değil, etiket ve yan taşır; renk yalnız eşlik eder (§2.2).
//
// Kartsız ve zeminsizdir (Bilal, 7 Eki 2026): sayfa zemininde açıkta durur,
// eskiden altında duran sayılar ve dürüstlük notu geniş ekranda iki yanına
// dizilir (yerleşim app/page.tsx'te). Ortadaki işaret site başlığındaki
// logonun animasyonunu oynar: kareler sırayla birbirinin yerine süzülür
// (`AnimasyonluMark` "rota").
//
// Esin: React Bits "Center Flow" (merkezden çevreye akan çizgiler). Kod bize
// ait: parlama/bulanıklık yok (§2.2), akış yönü içeri doğrudur ve yalnız
// ekrandayken oynar.
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { Compass, MapPin, PieChart, TrendingUp, Trophy, Users } from "lucide-react";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { cn } from "@/lib/utils";
// Sağ yarı (−60°, 0°, 60°) veri; sol yarı (120°, 180°, 240°) öğrencinin seçimi.
const DUGUMLER = [
{ etiket: "Taban sıralamaları", ikon: TrendingUp, aci: -60, kaynak: "veri" },
{ etiket: "Kontenjanlar", ikon: Users, aci: 0, kaynak: "veri" },
{ etiket: "Doluluk oranları", ikon: PieChart, aci: 60, kaynak: "veri" },
{ etiket: "Şehir tercihin", ikon: MapPin, aci: 120, kaynak: "sen" },
{ etiket: "Senin sıralaman", ikon: Trophy, aci: 180, kaynak: "sen" },
{ etiket: "İlgi alanların", ikon: Compass, aci: 240, kaynak: "sen" },
] as const;
/** viewBox 100×100: düğümlerin merkeze uzaklığı. Çizgi merkezden merkeze
* çekilir ve karoların altında kalır; akan kare ise karoların dışında
* kalan aralıkta yürür (iki uçtaki boşluk). */
const YARICAP = 36;
const DIS_BOSLUK = 9.5;
const IC_BOSLUK = 15.5;
/** Akan karenin kenarı (hero noktasının viewBox karşılığı). */
const KARE = 2.4;
const nokta = (aci: number, r: number) => {
const rad = (aci * Math.PI) / 180;
return { x: 50 + Math.cos(rad) * r, y: 50 + Math.sin(rad) * r };
};
export function VeriAkisi({ className }: { className?: string }) {
const kapRef = useRef<HTMLDivElement>(null);
const [oynuyor, setOynuyor] = useState(false);
// Ekran dışındayken akış da işaret de durur (kareler CSS'te `data-oynuyor`
// ile, işaret `oynat` ile).
useEffect(() => {
const kap = kapRef.current;
if (!kap || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([kayit]) => {
setOynuyor(kayit.isIntersecting);
});
io.observe(kap);
return () => io.disconnect();
}, []);
return (
<figure className={cn("mx-auto w-full max-w-sm", className)}>
<div
ref={kapRef}
data-oynuyor={oynuyor ? "" : undefined}
role="img"
aria-label="Listen altı şeyden kurulur: senin sıralaman, ilgi alanların ve şehir tercihin; YÖK Atlas'ın taban sıralamaları, kontenjanları ve doluluk oranları."
className="group/akis relative aspect-square w-full"
>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full" aria-hidden>
{DUGUMLER.map((d, i) => {
const dugum = nokta(d.aci, YARICAP);
const dis = nokta(d.aci, YARICAP - DIS_BOSLUK);
const ic = nokta(d.aci, IC_BOSLUK);
return (
<g key={d.etiket}>
{/* Düz çizgi: karodan merkeze; uçları karoların altında */}
<line
x1={dugum.x}
y1={dugum.y}
x2={50}
y2={50}
stroke="var(--color-slate-300)"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
<rect
x={-KARE / 2}
y={-KARE / 2}
width={KARE}
height={KARE}
rx={0.6}
className={cn(
"kt-akis-kare",
d.kaynak === "sen" ? "fill-orange-500" : "fill-primary",
)}
style={
{
"--x1": `${dis.x}px`,
"--y1": `${dis.y}px`,
"--x2": `${ic.x}px`,
"--y2": `${ic.y}px`,
// Sırayla değil, dağınık kalkış: 0 · 1,1 · 0,5 · 1,6 · 0,3 · 1,3 sn
animationDelay: `${[0, 1.1, 0.5, 1.6, 0.3, 1.3][i]}s`,
} as CSSProperties
}
/>
</g>
);
})}
</svg>
{DUGUMLER.map((d) => {
const p = nokta(d.aci, YARICAP);
const ustte = d.aci < 0 || d.aci > 180;
return (
<div
key={d.etiket}
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center"
style={{ left: `${p.x}%`, top: `${p.y}%` }}
>
{/* Beyaz, dolu karo (çizgi altından geçer); yanı ikonun rengi söyler */}
<span className="flex size-14 items-center justify-center rounded-2xl border border-slate-200 bg-card">
<d.ikon
className={cn("size-6", d.kaynak === "sen" ? "text-orange-500" : "text-primary")}
aria-hidden
/>
</span>
{/* Üstteki iki düğümde etiket karonun üstünde: altında çizgi merkeze iniyor */}
<span
className={cn(
"absolute w-20 text-center text-xs font-semibold leading-tight text-slate-700",
ustte ? "bottom-full mb-1.5" : "top-full mt-1.5",
)}
>
{d.etiket}
</span>
</div>
);
})}
<div className="absolute top-1/2 left-1/2 flex size-24 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-3xl border border-slate-200 bg-card">
{/* Site başlığındaki logonun animasyonu (rota): orada üzerine
gelince, burada ekrandayken sürekli oynar. */}
<AnimasyonluMark oynat={oynuyor} className="size-14" />
</div>
</div>
<figcaption className="mt-8 text-center text-sm leading-relaxed text-slate-500">
Solda senin seçimlerin, sağda YÖK Atlas verisi. Yapay zekâ yalnız bu
ikisinin kesiştiği programlardan seçer.
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,129 @@
import "server-only";
// Ana sayfadaki iki etkileşimli parçanın verisi (sunucuda, build/istek anında):
// - Kart destesi demosu (DESIGN.md §4.27): örnek bir sıralamaya göre GERÇEK
// programlar. Uydurma taban sıralaması gösterilmez; veri okunamazsa bölüm
// hiç çizilmez.
// - Süzülen haplar (§4.30): yalnız katalogda sayfası olan bölüm ve şehirler.
import { dilimAra } from "@/lib/db";
import { bolumSlugFromIsim, getIlBySlug } from "@/lib/katalog";
import { ozelKontenjanSatiriMi } from "@/lib/rapor-havuzu";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumBazAdi, ilSayfaHref, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
import type { DilimKey } from "@/types/yokatlas";
/** Demonun örnek öğrencisi: Sayısal, 85.000. sıra (§10.1'deki örnekle aynı). */
export const ORNEK_SIRA = 85_000;
const ORNEK_TUR = "say" as const;
export interface OrnekProgram {
id: string;
isim: string;
universite: string;
il: string | null;
tabanSira: number;
risk: RiskSeviyesi;
}
/**
* Her dilimden birkaç program: sınıra yapışık olanlar yerine dilimin içine
* yayılmış (riskli / az riskli / güvenli farkı kartta görünsün), her üniversite
* ve her bölüm adı en çok bir kez.
*/
export function ornekProgramlar(): OrnekProgram[] {
try {
const gorulenUni = new Set<string>();
const gorulenBolum = new Set<string>();
const sec = (dilim: DilimKey, adet: number, adim: number): OrnekProgram[] => {
const { programlar } = dilimAra(ORNEK_SIRA, ORNEK_TUR, dilim, {
uniturGrubu: "devlet",
barajDisiHaric: true,
limit: 600,
});
const secilen: OrnekProgram[] = [];
for (let i = adim; i < programlar.length && secilen.length < adet; i += adim) {
const p = programlar[i];
const bolum = bolumBazAdi(p.isim);
const risk = riskHesapla(p.efektifSira, ORNEK_SIRA);
if (
p.efektifSira == null ||
!risk ||
ozelKontenjanSatiriMi(p.isim) ||
/\(/.test(p.isim) ||
gorulenUni.has(p.universite) ||
gorulenBolum.has(bolum)
) {
// Elenen satırın bir sonrakine bak; adım bozulmasın diye geri al
i -= adim - 1;
continue;
}
gorulenUni.add(p.universite);
gorulenBolum.add(bolum);
secilen.push({
id: p.id,
isim: p.isim,
universite: trBaslikDuzeni(p.universite),
il: p.il ? trBaslikDuzeni(p.il) : null,
tabanSira: p.efektifSira,
risk,
});
}
return secilen;
};
const hayal = sec("hayal", 2, 60);
const dengeli = sec("dengeli", 3, 45);
const guvenli = sec("garanti", 2, 90);
// Sıra karışık: aynı risk art arda gelmesin
return [dengeli[0], hayal[0], guvenli[0], dengeli[1], hayal[1], dengeli[2], guvenli[1]].filter(
(p): p is OrnekProgram => Boolean(p),
);
} catch {
return [];
}
}
export interface KatalogHapi {
etiket: string;
href: string;
tur: "bolum" | "sehir";
}
const HAP_BOLUMLERI = [
"Tıp",
"Hukuk",
"Bilgisayar Mühendisliği",
"Psikoloji",
"Diş Hekimliği",
"Hemşirelik",
"Mimarlık",
"Yazılım Mühendisliği",
"Eczacılık",
"İşletme",
];
const HAP_SEHIRLERI = ["İstanbul", "Ankara", "İzmir", "Eskişehir", "Bursa", "Antalya"];
/** Bölüm ve şehir hapları; katalogda karşılığı olmayan ad sessizce düşer. */
export function katalogHaplari(): KatalogHapi[] {
try {
const bolumler: KatalogHapi[] = HAP_BOLUMLERI.flatMap((ad) => {
const slug = bolumSlugFromIsim(ad);
return slug ? [{ etiket: ad, href: `/bolum/${slug}`, tur: "bolum" as const }] : [];
});
const sehirler: KatalogHapi[] = HAP_SEHIRLERI.flatMap((ad) =>
getIlBySlug(turkishSlugify(ad))
? [{ etiket: ad, href: ilSayfaHref(ad), tur: "sehir" as const }]
: [],
);
// Bölüm ve şehir karışık dizilsin: her üç bölümden sonra bir şehir
const sonuc: KatalogHapi[] = [];
let s = 0;
bolumler.forEach((b, i) => {
sonuc.push(b);
if (i % 2 === 1 && s < sehirler.length) sonuc.push(sehirler[s++]);
});
return [...sonuc, ...sehirler.slice(s)];
} catch {
return [];
}
}

View File

@@ -41,8 +41,12 @@ const ARAC_ETIKETI: Record<string, { surer: string; bitti: string }> = {
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 };

View File

@@ -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<HTMLDivElement>(null);
const sohbetSutunuRef = useRef<HTMLDivElement>(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.
<div
ref={izgaraRef}
className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-6 lg:[--sohbet-kolon:380px] lg:grid-cols-[minmax(0,1fr)_var(--sohbet-kolon)] xl:[--sohbet-kolon:440px]"
@@ -299,16 +328,21 @@ export function ListemGovde({
)}
{hasPaket ? (
<>
<Button
asChild
variant="outline"
className={IKINCIL_BUTON}
>
<Link href="#revizyon">
{/* Revizyon sohbette yaşar (7 Eki 2026): düğme danışman
kutusunu odaklar, aday ne değişsin istediğini yazar;
danışman gerekirse soru formuyla netleştirip listeyi
yeniden kurar. Hak bitince düğme kalkar. */}
{kalanHak > 0 ? (
<Button
type="button"
variant="outline"
onClick={() => sohbetiOdakla("Listemde şunu değiştir: ")}
className={IKINCIL_BUTON}
>
<PencilLine className="size-4" aria-hidden />
Revize et
</Link>
</Button>
</Button>
) : null}
<Button
asChild
variant="outline"
@@ -371,26 +405,38 @@ export function ListemGovde({
/>
</section>
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
</div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
<div
ref={sohbetSutunuRef}
className="relative max-lg:contents lg:sticky lg:top-28"
>
{/* Liste ⇄ sohbet bölmesi: sütun aralığında durur, yalnız geniş ekranda */}
<BolmeTutamaci
etiket="Danışman sohbeti"
kontrol="sohbet"
yon={-1}
oku={() =>
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 ? (
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
) : (
<div className="flex flex-col gap-6">
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
@@ -399,14 +445,36 @@ export function ListemGovde({
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
className="h-[480px]"
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
revizyonHakki={kalanHak}
/>
</div>
) : (
<div className="flex flex-col gap-6 max-lg:contents">
{/* Sohbet paket kartının üstünde: #sohbet çapası doğrudan
danışmana düşsün. */}
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
<SohbetClient
baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet}
uyarilar={uyarilar}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri}
// Paketsizde 480 px; kısa ekranda paketlideki boyla aynı tavana
// iner (SOHBET_DAR_BOY, sohbet-client.tsx), alt bulanık şeridin
// altına girmez.
className="h-[480px] max-h-[calc(100dvh-23.75rem)] min-h-[340px] sm:max-h-[calc(100dvh-24.75rem)] lg:max-h-[calc(100dvh-21.5rem)]"
genis={sohbetGenis}
onGenisDegistir={sohbetGenisligiAyarla}
/>
</div>
<section
ref={kilitKartRef}
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm"
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm max-lg:order-last"
>
<div className="border-b border-slate-100 p-6">
<h3 className="font-heading text-xl font-bold tracking-tight">

View File

@@ -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<string | null>(null);
if (kalanHak <= 0) {
return (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-card 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 {
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 (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-card 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, güvenli dilimi genişlet…"
disabled={pending}
className="w-full rounded-lg border border-slate-200 bg-card 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"
>
<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>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
// Danışmanın soru formu (DESIGN.md §4.20, §4.33). `soru_sor` aracı
// (lib/ai/araclar.ts) liste revizyonu isteği belirsizken 1-3 kısa soruyu bu
// formla sorar; sorular tek tek gösterilir (shadcn Questionnaire,
// components/ui/questionnaire.tsx). Gönderilince cevaplar hem adayın balonuna
// okunur metin olarak (lib/ai/sohbet-meta.ts anketCevabiMetni) hem sunucuya
// yapısal olarak gider; bu mesaj kredi harcamaz. Form yalnız sohbetin SON
// danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey
// yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan
// aynı hâlde döner).
import { useId } from "react";
import { Check, MessageSquareText } from "lucide-react";
import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire";
export type AnketDurumu = "acik" | "gonderiliyor" | "cevaplandi" | "kapandi";
export function SohbetAnketi({
anket,
durum,
onGonder,
}: {
anket: Anket;
durum: AnketDurumu;
onGonder: (cevaplar: AnketCevaplari) => void;
}) {
const kimlik = useId();
if (durum === "cevaplandi" || durum === "kapandi") {
return (
<p className="flex items-center gap-1.5 px-0.5 text-xs font-medium text-slate-500">
{durum === "cevaplandi" ? (
<Check className="size-3.5 text-emerald-600" aria-hidden />
) : (
<MessageSquareText className="size-3.5" aria-hidden />
)}
{durum === "cevaplandi" ? "Formu cevapladın." : "Form kapandı; isteğini yazarak söyleyebilirsin."}
</p>
);
}
const items = anket.sorular.map((s) => ({
name: s.ad,
required: s.zorunlu !== false,
choices: s.secenekler.map((o) => ({ value: o.deger })),
}));
function gonder(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (durum === "gonderiliyor") return;
const veri = new FormData(e.currentTarget);
const cevaplar: AnketCevaplari = {};
for (const s of anket.sorular) {
const hepsi = veri
.getAll(s.ad)
.map((v) => String(v).trim())
.filter(Boolean);
if (hepsi.length === 0) continue;
cevaplar[s.ad] = s.coklu ? [...new Set(hepsi)] : hepsi[hepsi.length - 1];
}
if (Object.keys(cevaplar).length === 0) return;
onGonder(cevaplar);
}
return (
// Sade kutu (§4.4); balonun altında, aynı sütunda durur
<Questionnaire
aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined}
aria-label={anket.baslik ? undefined : "Danışmanın sorusu"}
className="rounded-2xl border border-slate-200 bg-card p-4"
defaultItem={anket.sorular[0]?.ad}
items={items}
shortcuts="letters"
onSubmit={gonder}
>
<div className="flex items-center justify-between gap-3">
<p id={`${kimlik}-baslik`} className="text-xs font-semibold text-slate-500">
{anket.baslik ?? "Danışmanın sorusu"}
</p>
<QuestionnaireProgress
render={(props, state) => (
<div {...props}>
{state.current} / {state.total}
</div>
)}
/>
</div>
{anket.sorular.map((s) => (
<QuestionnaireItem
key={s.ad}
name={s.ad}
multiple={s.coklu === true}
required={s.zorunlu !== false}
disabled={durum === "gonderiliyor"}
>
<QuestionnaireTitle>{s.soru}</QuestionnaireTitle>
{s.aciklama ? <QuestionnaireDescription>{s.aciklama}</QuestionnaireDescription> : null}
<QuestionnaireChoices>
{s.secenekler.map((o) => (
<QuestionnaireChoice key={o.deger} value={o.deger}>
<span>{o.etiket}</span>
{o.aciklama ? (
<QuestionnaireChoiceDescription>{o.aciklama}</QuestionnaireChoiceDescription>
) : null}
</QuestionnaireChoice>
))}
{s.serbest ? (
<QuestionnaireInput aria-label="Kendi cevabın" placeholder={s.serbest} maxLength={200} />
) : null}
</QuestionnaireChoices>
<QuestionnaireError>
{s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."}
</QuestionnaireError>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious size="sm" />
<QuestionnaireSkip size="sm" />
<QuestionnaireNext size="sm" />
<QuestionnaireSubmit size="sm" disabled={durum === "gonderiliyor"}>
{durum === "gonderiliyor" ? "Gönderiliyor…" : "Cevapları gönder"}
</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
);
}

View File

@@ -17,7 +17,7 @@ import {
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { cn } from "@/lib/utils";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
@@ -58,6 +58,14 @@ import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
import { olay } from "@/lib/analitik";
import type { ProfilAlani } from "@/lib/danisman-profil";
import {
anketCevabiMetni,
type Anket,
type AnketCevaplari,
type RevizyonOzeti,
type SohbetMeta,
} from "@/lib/ai/sohbet-meta";
import { SohbetAnketi, type AnketDurumu } from "./sohbet-anketi";
/** /api/soru/profil'in döndüğü, öğrenciye okunan profil satırı. */
export type ProfilSatiri = { alan: ProfilAlani; metin: string };
@@ -66,11 +74,72 @@ export interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
/** Yapısal ek: soru formu / form cevabı / revizyon sonucu (lib/ai/sohbet-meta.ts) */
meta?: SohbetMeta | null;
}
/** Kayıtlı (düz metin) mesajı useChat'in parçalı mesajına çevirir. */
/** Kayıttan gelen araç parçalarının kimlik öneki: canlı akışın parçalarından ayırt edilir. */
const KAYIT_PARCA_ONEKI = "kayit-";
/**
* Kayıtlı mesajı useChat'in parçalı mesajına çevirir. Kayıt düz metindir;
* soru formu ve revizyon sonucu `meta`dan araç parçası olarak geri kurulur ki
* canlı akışla aynı yoldan çizilsin (form, "Listeni yeniden kurdum" hapı).
*/
function uiMesaj(m: Mesaj): UIMessage {
return { id: m.id, role: m.role, parts: [{ type: "text", text: m.content }] };
const parts: UIMessage["parts"] = [{ type: "text", text: m.content }];
if (m.role === "assistant" && m.meta?.anket) {
parts.push({
type: "tool-soru_sor",
toolCallId: `${KAYIT_PARCA_ONEKI}anket-${m.meta.anket.id}`,
state: "output-available",
input: {},
output: { soruldu: true, anket: m.meta.anket },
} as UIMessage["parts"][number]);
}
if (m.role === "assistant" && m.meta?.revizyon?.ok) {
parts.push({
type: "tool-liste_revize",
toolCallId: `${KAYIT_PARCA_ONEKI}revizyon-${m.id}`,
state: "output-available",
input: {},
output: m.meta.revizyon,
} as UIMessage["parts"][number]);
}
return { id: m.id, role: m.role, parts };
}
/** Mesajdaki soru formu (soru_sor aracının çıktısı); yoksa null. */
function mesajinAnketi(m: UIMessage): Anket | null {
for (const p of m.parts) {
if (p.type !== "tool-soru_sor" || !("state" in p) || p.state !== "output-available") continue;
const cikti = p.output as { anket?: Anket } | undefined;
if (cikti?.anket?.sorular?.length) return cikti.anket;
}
return null;
}
/** Mesajdaki başarılı liste revizyonları: parça kimliği → özet. */
function mesajinRevizyonlari(m: UIMessage): { id: string; ozet: RevizyonOzeti }[] {
const sonuc: { id: string; ozet: RevizyonOzeti }[] = [];
for (const p of m.parts) {
if (p.type !== "tool-liste_revize" || !("state" in p) || p.state !== "output-available") continue;
const cikti = p.output as RevizyonOzeti | undefined;
if (cikti?.ok) sonuc.push({ id: p.toolCallId, ozet: cikti });
}
return sonuc;
}
/** Formun cevabı olan kullanıcı mesajı mı (anketCevabiMetni ile aynı başlık). */
const FORM_CEVABI_BASI = "Form cevaplarım:";
/**
* Sayfadaki "Revize et" düğmesi (listem-govde.tsx) sohbeti bu olayla odaklar:
* kutuya başlangıç metni yazılır, panel görünür yere kaydırılır, imleç kutuya gelir.
*/
export const SOHBET_ODAK_OLAYI = "kolaytercih:sohbet-odak";
export function sohbetiOdakla(metin?: string) {
window.dispatchEvent(new CustomEvent(SOHBET_ODAK_OLAYI, { detail: { metin } }));
}
/** Mesajın metni: araç turları arasındaki metin parçaları boş satırla birleşir. */
@@ -111,6 +180,20 @@ export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.
*/
const USTE_UZAKLIK_REM = 7;
const ALT_BLUR_REM = 7;
/**
* Dar hâlin boyu (Bilal, 7 Eki 2026: "yüksekliğini alttan kısalım, progressive
* blur üzerine geliyor, sığmıyor"). Panelin altı, sayfa en üstteyken bile alt
* bulanık şeridin yaklaşık 1rem üstünde biter. Ekrandan düşülen pay üç
* parçadır: panelin sayfa en üstteykenki üst boşluğu + 7rem şerit + 1rem nefes.
* Üst boşluk ölçüldü (7 Eki 2026, canlı site): sezon bandı + site başlığının
* altı telefonda 171, 768 px'te 187, 1280 px'te 137 px; üstüne `main` py-12 ve
* ızgara mt-8 (80 px) gelir → 251 / 267 / 217 px. Eski tek değer (18rem) sezon
* bandını hesaba katmıyordu: giriş kutusu masaüstünde ~40 px, telefonda ~90 px
* şeridin altında kalıyordu. Sezon bandının metni uzayıp satır atlarsa bu
* sayılar yeniden ölçülür. En az boy çok kısa ekran içindir.
*/
export const SOHBET_DAR_BOY =
"h-[calc(100dvh-23.75rem)] min-h-[340px] sm:h-[calc(100dvh-24.75rem)] lg:h-[calc(100dvh-21.5rem)]";
const GENIS_EN_AZ_PX = 380;
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
const GENIS_YUKSEKLIK = "";
@@ -128,10 +211,11 @@ function sohbetKanaliKur(onBasladi: (krediDustu: boolean) => void) {
let red: Red | null = null;
const transport = new DefaultChatTransport<UIMessage>({
api: "/api/soru",
prepareSendMessagesRequest: ({ messages }) => {
// `body`: sendMessage'a verilen ek gövde (soru formunun yapısal cevabı)
prepareSendMessagesRequest: ({ messages, body }) => {
const son = messages[messages.length - 1];
return {
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id },
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id, ...(body ?? {}) },
};
},
fetch: async (girdiUrl, init) => {
@@ -245,6 +329,8 @@ const ORNEK_SORULAR = [
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
];
/** Revizyon hakkı olan (paketli) adaya listeyi sohbetten değiştirme örneği önde gelir. */
const REVIZYON_ORNEGI = "Listemde değişiklik istiyorum: ilk 5 tercihim İstanbul'dan olsun";
/**
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
@@ -263,6 +349,7 @@ export function SohbetClient({
genis: genisProp,
onGenisDegistir,
genisYukseklik = GENIS_YUKSEKLIK,
revizyonHakki = 0,
}: {
baslangicKredi: number;
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
@@ -294,6 +381,8 @@ export function SohbetClient({
/** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır
* (üst 7rem sticky hizası, alt 7rem progressive blur payı). */
genisYukseklik?: string;
/** Kalan liste revizyonu hakkı (paketli); 0 ise revizyon örneği gösterilmez. */
revizyonHakki?: number;
}) {
const [girdi, setGirdi] = useState("");
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
@@ -406,6 +495,41 @@ export function SohbetClient({
? [...mesajlar, BEKLENEN_CEVAP]
: mesajlar;
// Canlı akışta liste_revize başarıyla bitince liste sayfada yenilenir
// (sunucu bileşeni raporu yeniden okur). Kayıttan gelen parçalar (önek)
// zaten ekrandaki listeye ait; onlar için yenileme yok.
const islenenRevizyonlar = useRef(new Set<string>());
useEffect(() => {
for (const m of mesajlar) {
if (m.role !== "assistant") continue;
for (const r of mesajinRevizyonlari(m)) {
if (r.id.startsWith(KAYIT_PARCA_ONEKI) || islenenRevizyonlar.current.has(r.id)) continue;
islenenRevizyonlar.current.add(r.id);
router.refresh();
toast.success("Listen yeniden kuruldu.");
olay("liste_revize", { sayfa: "listem", yol: "sohbet" });
}
}
}, [mesajlar, router]);
// Sayfadaki "Revize et" düğmesi sohbeti odaklar (sohbetiOdakla)
useEffect(() => {
function odakla(e: Event) {
const metin = (e as CustomEvent<{ metin?: string }>).detail?.metin;
if (typeof metin === "string") setGirdi(metin);
const el = kapRef.current;
el?.scrollIntoView({ behavior: hareketAz ? "auto" : "smooth", block: "nearest" });
// Kaydırma başlasın, sonra odak (odak kaydırmayı yarıda keser)
window.setTimeout(() => {
const alan = el?.querySelector("textarea");
alan?.focus();
alan?.setSelectionRange(alan.value.length, alan.value.length);
}, 50);
}
window.addEventListener(SOHBET_ODAK_OLAYI, odakla);
return () => window.removeEventListener(SOHBET_ODAK_OLAYI, odakla);
}, [hareketAz]);
/**
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
* ve giriş kutusuna koy. Akış yarıda koptuysa tahmin etme: sunucu krediyi
@@ -536,6 +660,30 @@ export function SohbetClient({
void sendMessage({ id, role: "user", parts: [{ type: "text", text: mesaj }] });
}
/**
* Soru formunun cevabı: balonda okunur metin, gövdede yapısal cevap. Kredi
* harcamaz (sunucu formu son danışman mesajıyla eşleştirip bedelsiz sayar),
* bu yüzden kredi kapısından geçmez.
*/
function anketiGonder(anket: Anket, cevaplar: AnketCevaplari) {
if (bekliyor) return;
const metin = anketCevabiMetni(anket, cevaplar);
const id = crypto.randomUUID();
sonSoruRef.current = null;
void sendMessage(
{ id, role: "user", parts: [{ type: "text", text: metin }] },
{ body: { anketCevabi: { anketId: anket.id, cevaplar } } },
);
}
/** Formun durumu: yalnız son danışman mesajındaki form açıktır. */
function anketDurumu(i: number): AnketDurumu {
const sonraki = gorunenMesajlar[i + 1];
if (!sonraki) return bekliyor ? "gonderiliyor" : "acik";
if (sonraki.role === "user" && mesajMetni(sonraki).startsWith(FORM_CEVABI_BASI)) return "cevaplandi";
return "kapandi";
}
/**
* Sohbet geçmişini hem sunucudan hem ekrandan siler. Krediler iade
* edilmez; danışman bir sonraki soruda sıfır geçmişle başlar (rapor özeti
@@ -569,10 +717,7 @@ export function SohbetClient({
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
// 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
// sohbeti sıkıştırıyordu, 10-14rem denemeleri fazla yüksek geldi;
// panel görünür ama sayfaya baskın değil.
className ?? "h-[calc(100dvh-18rem)] min-h-[380px]",
className ?? SOHBET_DAR_BOY,
)}
>
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
@@ -796,7 +941,7 @@ export function SohbetClient({
!ozet && "justify-center",
)}
>
{ORNEK_SORULAR.map((s) => (
{[...(revizyonHakki > 0 ? [REVIZYON_ORNEGI] : []), ...ORNEK_SORULAR].map((s) => (
<PromptSuggestion
key={s}
size="sm"
@@ -842,6 +987,8 @@ export function SohbetClient({
// Danışman veriye bakarken ne yaptığını hap olarak söyler
// (arac-pilleri.tsx); haplar cevap bitince de kalır.
const piller = aracPilleri(m);
// Soru formu balonun altında (sohbet-anketi.tsx)
const anket = mesajinAnketi(m);
return (
<Message
key={cevapAnahtari(gorunenMesajlar, i)}
@@ -868,6 +1015,13 @@ export function SohbetClient({
) : (
<CevapIskeleti />
)}
{anket ? (
<SohbetAnketi
anket={anket}
durum={anketDurumu(i)}
onGonder={(cevaplar) => anketiGonder(anket, cevaplar)}
/>
) : null}
</div>
</Message>
);
@@ -917,7 +1071,11 @@ export function SohbetClient({
className="rounded-2xl border-slate-200"
>
<PromptInputTextarea
placeholder="Sorunu yaz… (1 kredi)"
placeholder={
revizyonHakki > 0
? "Sorunu yaz ya da listende ne değişsin söyle… (1 kredi)"
: "Sorunu yaz… (1 kredi)"
}
maxLength={2000}
/>
<PromptInputActions className="justify-end pt-1">

View File

@@ -19,6 +19,79 @@ export type Senaryo = {
const ARAC_BEKLEME = 1400;
export const SENARYOLAR: Senaryo[] = [
{
ad: "Soru formu + revizyon",
aciklama:
"Belirsiz revizyon isteği: danışman soru formu açar (soru_sor), cevap gelince listeyi yeniden kurar (liste_revize).",
kredi: 12,
transport: () =>
createChat()
.user("Listemde daha fazla İstanbul olsun")
.sleep(600)
.assistant(({ writer }) => {
writer
.tool("soru_sor", {
input: { sorular: [] },
})
.sleep(700)
.output({
soruldu: true,
anket: {
id: "demo-anket",
baslik: "Listeni nasıl değiştirelim?",
sorular: [
{
ad: "adet",
soru: "İlk kaç tercihin İstanbul'dan olsun?",
secenekler: [
{ deger: "3", etiket: "İlk 3" },
{ deger: "5", etiket: "İlk 5" },
{ deger: "10", etiket: "İlk 10" },
],
},
{
ad: "kapsam",
soru: "İstanbul dışındaki iller listede kalsın mı?",
aciklama: "Kalırsa liste yine 24 tercih; yalnız başı İstanbul olur.",
secenekler: [
{ deger: "kalsin", etiket: "Kalsın", aciklama: "Ankara ve İzmir de listede olsun" },
{ deger: "yalniz", etiket: "Yalnız İstanbul" },
],
},
],
},
});
writer.text("Birkaç kısa soru, sonra listeni yeniden kurayım.");
})
.user("Form cevaplarım:\n- İlk kaç tercihin İstanbul'dan olsun? → İlk 5\n- İstanbul dışındaki iller listede kalsın mı? → Kalsın")
.sleep(500)
.assistant(({ writer }) => {
writer
.tool("liste_revize", {
input: { ilkTercihler: { adet: 5, iller: ["İstanbul"] }, iller: { ekle: ["İstanbul"] } },
})
.sleep(2500)
.output({
ok: true,
toplam: 24,
dagilim: { hayal: 5, dengeli: 13, garanti: 6 },
kalanHak: 1,
ilkTercihler: [
"Bilgisayar Mühendisliği — İstanbul Teknik Üniversitesi (İSTANBUL)",
"Bilgisayar Mühendisliği — Boğaziçi Üniversitesi (İSTANBUL)",
"Yazılım Mühendisliği — Yıldız Teknik Üniversitesi (İSTANBUL)",
"Bilgisayar Mühendisliği — Marmara Üniversitesi (İSTANBUL)",
"Bilgisayar Mühendisliği — İstanbul Üniversitesi-Cerrahpaşa (İSTANBUL)",
],
});
writer.text(
"Listeni yeniden kurdum: ilk 5 tercihin artık İstanbul'dan, kalan 19 tercih Ankara ve İzmir'le devam ediyor. Dağılım yine 5 hayal / 13 dengeli / 6 güvenli.\n\n" +
"1. Bilgisayar Mühendisliği — İTÜ\n2. Bilgisayar Mühendisliği — Boğaziçi\n3. Yazılım Mühendisliği — YTÜ\n4. Bilgisayar Mühendisliği — Marmara\n5. Bilgisayar Mühendisliği — İÜ-Cerrahpaşa\n\n" +
"Kalan revizyon hakkın: 1.",
);
})
.transport({ delayMs: 20 }),
},
{
ad: "Tek araç",
aciklama: "Bir arama, sonra kısa cevap.",

View File

@@ -27,7 +27,7 @@
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {

View File

@@ -0,0 +1,164 @@
import "server-only";
import { and, eq, sql } from "drizzle-orm";
import { appDb, schema } from "@/lib/appdb";
import {
grantCredits,
krediBittiIsaretle,
MAX_REVIZYON,
RAPOR_KREDI,
spendCredits,
} from "@/lib/credits";
import { uretimKilidiAl, uretimKilidiBirak } from "@/lib/uretim-kilidi";
import { profilGetir } from "@/lib/danisman-profil-db";
import { listeOzetiCikar, raporUret, RaporUretimHatasi } from "@/lib/ai/rapor";
import { kurallariBirlestir } from "@/lib/revizyon-kurali";
import type { RevizyonOzeti } from "@/lib/ai/sohbet-meta";
import { kullanicininRaporu } from "./rapor-queries";
import type { RaporParams, RaporSonuc, RevizyonKurali } from "./types/rapor";
/**
* Sohbetten liste revizyonu (7 Eki 2026). Danışmanın `liste_revize` aracı
* (lib/ai/araclar.ts) burayı çağırır; eski /listem revizyon kutusu ve
* `listeRevize` sunucu aksiyonu kaldırıldı — tek yol budur.
*
* Koruma kapıları eski aksiyonla aynı: paket şartı, MAX_REVIZYON sayacı,
* kullanıcı başına üretim kilidi, `requestId` (= adayın sohbet mesajının
* kimliği) ile idempotent defter kaydı, başarısız üretimde iade. Bedel
* paketliye 0 (liste ve revizyon pakete dâhil); satır yine yazılır ki aynı
* mesaj ikinci kez revizyon tetiklemesin.
*/
export type RevizyonSonucu =
| { ok: true; ozet: Extract<RevizyonOzeti, { ok: true }>; rapor: RaporSonuc }
| { ok: false; neden: string; kod: "PAKET" | "HAK" | "RAPOR" | "SURUYOR" | "KREDI" | "HATA" };
function hataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message;
if (
err instanceof Error &&
/abort|timeout|timed out/i.test(`${err.name} ${err.constructor.name} ${err.message}`)
) {
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; revizyon hakkın kullanılmadı.";
}
console.error("[revizyon] üretim hatası:", err instanceof Error ? err.message : String(err));
return "Liste yeniden kurulurken bir sorun oluştu; revizyon hakkın kullanılmadı.";
}
function ozetCikar(rapor: RaporSonuc, revisionCount: number): Extract<RevizyonOzeti, { ok: true }> {
const say = (d: string) => rapor.tercihler.filter((t) => t.dilim === d).length;
return {
ok: true,
toplam: rapor.tercihler.length,
dagilim: { hayal: say("hayal"), dengeli: say("dengeli"), garanti: say("garanti") },
kalanHak: Math.max(0, MAX_REVIZYON - revisionCount),
ilkTercihler: rapor.tercihler.slice(0, 5).map((t) => {
const p = rapor.programlar[t.programId];
return `${p?.isim ?? "?"} — ${p?.universite ?? "?"}${p?.il ? ` (${p.il})` : ""}`;
}),
};
}
export async function listeRevizeCekirdek(input: {
userId: string;
/** Idempotency anahtarı: adayın sohbet mesajının kimliği. */
requestId: string;
kural: RevizyonKurali;
}): Promise<RevizyonSonucu> {
const user = await appDb.query.user.findFirst({
where: eq(schema.user.id, input.userId),
columns: { hasPaket: true, creditBalance: true },
});
if (!user) return { ok: false, kod: "HATA", neden: "Kullanıcı bulunamadı." };
if (!user.hasPaket) {
return {
ok: false,
kod: "PAKET",
neden: "Liste revizyonu Tercih Dönemi Paketi'ne dâhil; aday paketsiz.",
};
}
const rapor = await kullanicininRaporu(input.userId);
if (!rapor?.result) {
return { ok: false, kod: "RAPOR", neden: "Adayın kayıtlı listesi yok; önce liste oluşturulmalı." };
}
if (rapor.revisionCount >= MAX_REVIZYON) {
return {
ok: false,
kod: "HAK",
neden: `Revizyon hakkı doldu (en fazla ${MAX_REVIZYON}).`,
};
}
const kilit = await uretimKilidiAl(input.userId, input.requestId);
if (!kilit.ok) {
return { ok: false, kod: "SURUYOR", neden: "Liste şu anda zaten yeniden kuruluyor." };
}
try {
const eskiParams = rapor.params as RaporParams;
const onceki = rapor.result as RaporSonuc;
const params: RaporParams = {
...eskiParams,
revizyon: kurallariBirlestir(eskiParams.revizyon, input.kural),
};
const maliyet = user.hasPaket ? 0 : RAPOR_KREDI;
const harcama = await spendCredits({
userId: input.userId,
amount: maliyet,
reason: "report_revision",
refId: input.requestId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(input.userId);
return { ok: false, kod: "KREDI", neden: `Revizyon için ${RAPOR_KREDI} kredi gerekli.` };
}
// DUPLICATE: aynı mesaj ikinci kez (ağ tekrarı). İade varsa önceki deneme
// başarısızdı; yoksa revizyon zaten yapıldı → güncel listeyi döndür.
const iade = await appDb.query.creditLedger.findFirst({
where: and(
eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId),
),
});
if (iade) return { ok: false, kod: "HATA", neden: "Önceki deneme başarısız olmuştu; yeni bir mesajla tekrar iste." };
const guncel = await kullanicininRaporu(input.userId);
if (guncel?.result) {
const sonuc = guncel.result as RaporSonuc;
return { ok: true, ozet: ozetCikar(sonuc, guncel.revisionCount), rapor: sonuc };
}
return { ok: false, kod: "SURUYOR", neden: "Liste şu anda zaten yeniden kuruluyor." };
}
let sonuc: RaporSonuc;
try {
const profil = await profilGetir(input.userId).catch(() => undefined);
sonuc = await raporUret(
params,
input.kural.not?.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
profil,
);
} catch (err) {
await grantCredits({
userId: input.userId,
delta: maliyet,
reason: "refund",
refId: input.requestId,
}).catch(() => {});
return { ok: false, kod: "HATA", neden: hataMesaji(err) };
}
await appDb
.update(schema.reports)
.set({
params,
result: sonuc,
revisionCount: sql`${schema.reports.revisionCount} + 1`,
updatedAt: new Date(),
})
.where(eq(schema.reports.id, rapor.id));
return { ok: true, ozet: ozetCikar(sonuc, rapor.revisionCount + 1), rapor: sonuc };
} finally {
await uretimKilidiBirak(input.userId, input.requestId);
}
}

View File

@@ -1,6 +1,6 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
uretimKilidiAl,
uretimKilidiBirak,
@@ -15,13 +15,8 @@ import {
grantCredits,
krediBittiIsaretle,
RAPOR_KREDI,
MAX_REVIZYON,
} from "@/lib/credits";
import {
raporUret,
listeOzetiCikar,
RaporUretimHatasi,
} from "@/lib/ai/rapor";
import { raporUret, RaporUretimHatasi } from "@/lib/ai/rapor";
import type {
RaporParams,
RaporSonuc,
@@ -31,6 +26,8 @@ import { raporMaskele } from "@/lib/rapor-maske";
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
// Liste revizyonu burada değil: sohbetteki liste_revize aracı
// (features/rapor/liste-revizyon.ts) tek yoldur (7 Eki 2026).
export type ListeSonuc =
| {
ok: true;
@@ -259,133 +256,3 @@ async function listeOlusturKilitli(
hasPaket: user.hasPaket,
};
}
export async function listeRevize(input: {
feedback: string;
requestId: string;
}): Promise<ListeSonuc> {
const session = await getSession();
if (!session) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
const user = await getCurrentUser();
if (!user) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
if (!user.hasPaket) return { ok: false, code: "PAKET", error: "Paket gerekli." };
const feedback = (input.feedback ?? "").trim();
if (feedback.length < 5) {
return {
ok: false,
code: "HATA",
error: "Ne değişsin istediğini kısaca yazmalısın.",
};
}
if (!input.requestId) {
return { ok: false, code: "HATA", error: "Geçersiz istek." };
}
const rapor = await kullanicininRaporu(user.id);
if (!rapor?.result) {
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
}
if (rapor.revisionCount >= MAX_REVIZYON) {
return {
ok: false,
code: "HATA",
error: `Revizyon hakkın doldu (en fazla ${MAX_REVIZYON}).`,
};
}
const params = rapor.params as RaporParams;
const onceki = rapor.result as RaporSonuc;
// Revizyon pakete dâhil (bkz. listeOlusturKilitli'deki aynı not). Yukarıdaki
// hasPaket kapısı yüzünden burada maliyet her zaman 0; ifade yine de koşullu
// yazılıyor ki kapı ileride gevşerse paketsiz kullanıcıdan ücret düşmeye
// devam etsin. Revizyon hakkını MAX_REVIZYON sayacı sınırlıyor, kredi değil.
const maliyet = user.hasPaket ? 0 : RAPOR_KREDI;
const harcama = await spendCredits({
userId: user.id,
amount: maliyet,
reason: "report_revision",
refId: input.requestId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`,
};
}
// DUPLICATE: bu requestId zaten işlendi. İade varsa önceki deneme
// başarısızdı → taze bir denemeye yönlendir. Yoksa revizyon tamamlanmış
// ama cevap istemciye ulaşmamıştır → güncel raporu döndür.
const iade = await appDb.query.creditLedger.findFirst({
where: and(
eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId),
),
});
if (iade) {
return {
ok: false,
code: "HATA",
error:
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
};
}
const guncel = await kullanicininRaporu(user.id);
if (guncel?.result) {
return {
ok: true,
rapor: guncel.result as RaporSonuc,
params: guncel.params as RaporParams,
revisionCount: guncel.revisionCount,
kredi: user.creditBalance,
hasPaket: true,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
}
let sonuc: RaporSonuc;
try {
const profil = await profilGetir(user.id).catch(() => undefined);
sonuc = await raporUret(
params,
feedback.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
profil,
);
} catch (err) {
// İade satırı burada da yazılır (paketlide delta 0) — DUPLICATE dalındaki
// "önceki deneme başarısızdı" çıkarımı buna dayanıyor.
await grantCredits({
userId: user.id,
delta: maliyet,
reason: "refund",
refId: input.requestId,
}).catch(() => {});
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
}
// Sayaç yalnızca başarılı üretimde artar
await appDb
.update(schema.reports)
.set({
result: sonuc,
revisionCount: sql`${schema.reports.revisionCount} + 1`,
updatedAt: new Date(),
})
.where(eq(schema.reports.id, rapor.id));
return {
ok: true,
rapor: sonuc,
params,
revisionCount: rapor.revisionCount + 1,
kredi: user.creditBalance - maliyet,
hasPaket: true,
};
}

View File

@@ -51,6 +51,7 @@ export async function sohbetGecmisiGetir(userId: string) {
id: schema.chatMessages.id,
role: schema.chatMessages.role,
content: schema.chatMessages.content,
meta: schema.chatMessages.meta,
})
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, userId))

View File

@@ -81,4 +81,48 @@ export interface RaporParams {
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
il?: string;
notlar?: string;
/**
* Sohbetten yapılan liste revizyonlarının yapısal kuralları (7 Eki 2026).
* Sihirbaz seçimlerinin ÜSTÜNE uygulanır; sihirbaz seçimleri değişmediği
* için "Tercihlerin değişmiş" farkına girmez (tercih-farki.ts yalnız adlı
* alanlara bakar). Her yeni revizyonda üst anahtar bazında birleştirilir.
*/
revizyon?: RevizyonKurali;
}
/** "yalnız / ekle / çıkar" üçlüsü: sihirbaz listesine göre fark. */
export type ListeFarki = {
yalniz?: string[];
ekle?: string[];
cikar?: string[];
};
/**
* Sohbetten gelen revizyon kuralları (lib/ai/araclar.ts `liste_revize`).
* Serbest metin yerine yapısal: havuz süzgeci, iskelet ve sıra koddan
* uygulanır; model yalnız seçer ve gerekçe yazar. Eksik alan "dokunma" demektir.
*/
export type RevizyonKurali = {
iller?: ListeFarki;
kategoriler?: ListeFarki;
universiteTipi?: UniturGrubu | "farketmez";
/** Dilim iskeleti: standart 5/13/6, güvenli ağırlıklı 4/12/8, hayal ağırlıklı 7/12/5. */
iskelet?: "standart" | "guvenli-agirlikli" | "hayal-agirlikli";
/** "İlk N tercih şu ölçüte uysun": listenin başı bu ölçütle dolar. */
ilkTercihler?: {
adet: number;
iller?: string[];
kategoriler?: string[];
universiteTipi?: UniturGrubu;
};
/** Listeden ve havuzdan dışlanacaklar (ad eşleşmesi büyük-küçük harfsiz). */
cikar?: {
universiteler?: string[];
bolumler?: string[];
programIdler?: string[];
};
/** Listeye mutlaka girecek program kimlikleri (havuzda yoksa dilimine eklenir). */
ekle?: { programIdler: string[] };
/** Adayın isteğinin kısa özeti; modele revizyon notu olarak gider. */
not?: string;
};

View File

@@ -8,12 +8,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SecimHaplari } from "@/components/ui/secim-haplari";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerTitle,
} from "@/components/ui/drawer";
AltSayfa,
AltSayfaKapat,
AltSayfaGovde,
AltSayfaIcerik,
AltSayfaAciklama,
AltSayfaBaslik,
} from "@/components/ui/alt-sayfa";
import { PikselZemin } from "@/components/piksel-zemin";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import {
@@ -305,19 +306,15 @@ export function CtaSiraForm({
// başlığı yalnız ekran okuyucuya.
const siraAdimi = !kompakt && adim === "sira";
const cekmece = (
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
<DrawerContent
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
>
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<AltSayfa open={acik} onOpenChange={acikDegisti}>
<AltSayfaIcerik>
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</DrawerClose>
</AltSayfaKapat>
<div
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
>
<DrawerTitle
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik
className={
siraAdimi
? "px-10 text-center font-heading text-2xl font-bold text-slate-900"
@@ -325,8 +322,8 @@ export function CtaSiraForm({
}
>
Sıralamana yakın programlar hangileri?
</DrawerTitle>
<DrawerDescription
</AltSayfaBaslik>
<AltSayfaAciklama
className={
siraAdimi
? "mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500"
@@ -336,7 +333,7 @@ export function CtaSiraForm({
Puan türünü seç, sıralamanı yaz; hayal, dengeli ve güvenli
dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan
ayrılmadan gör. Ücretsiz, üyelik gerekmez.
</DrawerDescription>
</AltSayfaAciklama>
{siraAdimi ? (
<div className="mt-5">{siraFormu}</div>
) : facetler && sira != null ? (
@@ -351,9 +348,9 @@ export function CtaSiraForm({
onTamamla={secimleriTamamla}
/>
) : null}
</div>
</DrawerContent>
</Drawer>
</AltSayfaGovde>
</AltSayfaIcerik>
</AltSayfa>
);
if (kompakt) {

View File

@@ -20,12 +20,9 @@ import {
import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import {
profilDuzenlemeyiAc,
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
@@ -107,11 +104,10 @@ export function HeroForm() {
}
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
// (TercihProfiliKapisi, layout'ta monte) açar.
// Sıralamayı değiştirmek navbar'daki sıralama hapından yapılır.
if (kayitliProfil) {
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<div className="flex w-full max-w-md flex-col items-center">
<Button
asChild
size="lg"
@@ -122,18 +118,6 @@ export function HeroForm() {
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-sm text-slate-600">
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
devam edersin.{" "}
<button
type="button"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
>
Farklı sıralama gir
</button>
</p>
</div>
);
}

View File

@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "sonner";
import { toast } from "@/components/ui/bildirim";
import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
@@ -23,47 +23,8 @@ import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler";
import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum";
export function Cip({
secili,
onClick,
children,
kucuk = false,
ton = "notr",
className = "",
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
kucuk?: boolean;
/**
* bilgi: özel kontenjan durumu çipi (DESIGN.md §4.21) — pasif hâli bilgi
* mavisi tonunda (§2 `primary` rolü), seçili hâli nötr çiple aynı.
*/
ton?: "notr" | "bilgi";
className?: string;
}) {
// Şişko ve sade (DESIGN.md §1.1): çip 44 px dokunma hedefi; il
// çipleri (kucuk) çok sayıda olduğu için 40 px. Hover §2.1 tek turuncu.
const boyut = kucuk ? "h-10 px-3.5 text-sm" : "h-11 px-4 text-sm";
const temel = `inline-flex cursor-pointer items-center rounded-full border font-semibold transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] ${boyut} ${className}`;
const pasif =
ton === "bilgi"
? "border-primary/30 bg-primary/10 text-primary hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"
: "border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500";
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili ? `${temel} border-primary bg-primary text-white` : `${temel} ${pasif}`
}
>
{children}
</button>
);
}
// Seçim çipi tek kaynaktan (DESIGN.md §4.24): seçilince tik büyür, yazı kayar.
import { CipGrubu, SecimCipi as Cip } from "@/components/ui/cip-grubu";
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
@@ -337,7 +298,7 @@ export function SihirbazAdimlar({
<p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<CipGrubu etiket="İlgi alanları" className="mt-4">
{facetler.kategoriler.map((k) => (
<Cip
key={k.ad}
@@ -348,7 +309,7 @@ export function SihirbazAdimlar({
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</CipGrubu>
</div>
<div ref={panelRef}>
<h3 className="font-heading text-xl font-bold text-slate-900">
@@ -387,8 +348,9 @@ export function SihirbazAdimlar({
görünüm ilk 10 il; kalanı "+N il daha" rozetiyle açılır. Arama
yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
devre dışı bırakır. */}
<div
className={`mt-3 flex max-h-64 flex-wrap gap-2 overflow-y-auto sm:max-h-none sm:overflow-visible ${
<CipGrubu
etiket="Şehirler"
className={`mt-3 max-h-64 overflow-y-auto sm:max-h-none sm:overflow-visible ${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -424,7 +386,7 @@ export function SihirbazAdimlar({
Daha az göster
</button>
) : null}
</div>
</CipGrubu>
{ilAramaNorm &&
gorunenTurkiyeIlleri.length === 0 &&
@@ -439,8 +401,9 @@ export function SihirbazAdimlar({
<p className="mb-2 text-sm font-semibold text-slate-700">
KKTC & Yurt Dışı
</p>
<div
className={`flex flex-wrap gap-2 ${
<CipGrubu
etiket="KKTC ve yurt dışı"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -455,7 +418,7 @@ export function SihirbazAdimlar({
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
</Cip>
))}
</div>
</CipGrubu>
</div>
) : null}
@@ -500,8 +463,9 @@ export function SihirbazAdimlar({
<p className="mb-2 text-sm font-semibold text-slate-700">
Üniversite tipi
</p>
<div
className={`flex flex-wrap gap-2 ${
<CipGrubu
etiket="Üniversite tipi"
className={`${
facetYukleniyor ? "animate-pulse opacity-60" : ""
}`}
>
@@ -525,7 +489,7 @@ export function SihirbazAdimlar({
>
Fark etmez
</Cip>
</div>
</CipGrubu>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-slate-700">
@@ -533,7 +497,7 @@ export function SihirbazAdimlar({
</p>
{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda
yedi çip 44 px'le pencereyi 812 px'in dışına itiyordu. */}
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<CipGrubu etiket="Senin için önemli olan" className="grid grid-cols-2 sm:flex sm:flex-wrap">
{ONCELIKLER.map((o) => (
<Cip
key={o}
@@ -544,7 +508,7 @@ export function SihirbazAdimlar({
{o}
</Cip>
))}
</div>
</CipGrubu>
<p className="mt-2 text-sm text-slate-500">
Listeyi daraltmaz; programların sırasını ve gerekçelerini
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
@@ -559,7 +523,7 @@ export function SihirbazAdimlar({
Özel kontenjan durumun{" "}
<span className="font-normal text-slate-500">(isteğe bağlı)</span>
</p>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<CipGrubu etiket="Özel kontenjan durumun" className="grid grid-cols-2 sm:flex sm:flex-wrap">
{OZEL_DURUMLAR.map((d) => (
<Cip
key={d}
@@ -577,7 +541,7 @@ export function SihirbazAdimlar({
{OZEL_DURUM_ETIKET[d]}
</Cip>
))}
</div>
</CipGrubu>
<p className="mt-2 text-sm text-slate-500">
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
kontenjanı olan programlar listende öne çıkar ve danışman bunu

View File

@@ -1,10 +1,14 @@
/**
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
*
* İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* Üç tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
* (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
* üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
* (src/lib/danisman-profil.ts); `soru_sor` + `liste_revize` sohbetten liste
* revizyonu (7 Eki 2026): danışman belirsiz isteği form olarak sorar
* (lib/ai/sohbet-meta.ts), netleşince YAPISAL kuralla listeyi yeniden kurdurur
* (features/rapor/liste-revizyon.ts). Model listeyi yine kendisi kurmaz:
* üretim deterministik havuzda kalır (havuz-dışına-çıkamama kuralı); kural
* havuzu, iskeleti ve sırayı koddan değiştirir (lib/revizyon-kurali.ts).
*
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
*/
@@ -85,11 +89,19 @@ import {
} from "@/lib/danisman-profil";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
import { anketDogrula, type Anket } from "./sohbet-meta";
import type { RevizyonKurali, ListeFarki } from "@/features/rapor/types/rapor";
export type AracBaglami = {
userId: string;
sira: number;
tur: PuanTuruKey;
/** Paketli mi (liste_revize paket ister); verilmezse araç "paket yok" sayar. */
hasPaket?: boolean;
/** Adayın bu turdaki mesajının kimliği: liste_revize'nin idempotency anahtarı. */
mesajId?: string;
/** Kalan revizyon hakkı (prompt'a ve araç hatasına gider). */
kalanRevizyon?: number;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
/** Sihirbaz öncelik çipleri (uyum puanı / öne çıkan için) */
@@ -405,6 +417,123 @@ export const ARACLAR: AracTanimi[] = [
},
},
},
{
type: "function",
function: {
name: "soru_sor",
description:
"Adaya ekranda tek tek gösterilen kısa bir SORU FORMU açar (en çok 3 soru, soru başına 2-6 seçenek). Liste revizyonu isteği belirsizken kullan: hangi il, kaç tercih, ekle mi yalnız mı, hangi dilim. Her soruya net, kısa seçenekler yaz; gerekirse serbest metin alanı ekle (serbest). Form gösterildikten sonra BAŞKA ARAÇ ÇAĞIRMA ve cevabını tek kısa cümleyle bitir; adayın cevabı sonraki mesajda 'Form cevaplarım' olarak gelir, o zaman liste_revize çağır.",
parameters: {
type: "object",
properties: {
baslik: { type: "string", description: "Formun kısa başlığı (isteğe bağlı), ör. 'Listeni nasıl değiştirelim?'" },
sorular: {
type: "array",
description: "1-3 soru; sırayla gösterilir.",
items: {
type: "object",
properties: {
ad: { type: "string", description: "Cevap anahtarı: küçük harf, rakam, tire (ör. 'il', 'adet')." },
soru: { type: "string", description: "Adaya gösterilen soru metni." },
aciklama: { type: "string", description: "Sorunun altında küçük açıklama (isteğe bağlı)." },
secenekler: {
type: "array",
description: "2-6 seçenek.",
items: {
type: "object",
properties: {
deger: { type: "string", description: "Cevap değeri (kısa, ör. 'istanbul', '5')." },
etiket: { type: "string", description: "Adaya görünen metin." },
aciklama: { type: "string", description: "Seçeneğin altında küçük açıklama (isteğe bağlı)." },
},
required: ["deger", "etiket"],
additionalProperties: false,
},
},
coklu: { type: "boolean", description: "true: birden çok seçenek işaretlenebilir." },
serbest: { type: "string", description: "Verilirse serbest metin alanı eklenir; değer yer tutucudur (ör. 'Başka bir il yaz…')." },
zorunlu: { type: "boolean", description: "false: boş geçilebilir (varsayılan true)." },
},
required: ["ad", "soru", "secenekler"],
additionalProperties: false,
},
},
},
required: ["sorular"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "liste_revize",
description:
"Adayın 24'lük listesini verilen YAPISAL kurallarla yeniden kurar (paketli adayda, en fazla 2 hak). Kural kodla uygulanır: il/alan/tip farkı aday havuzunu değiştirir, 'ilkTercihler' listenin başını o ölçütle doldurur, 'cikar' havuzdan düşer, 'ekle' program kimliklerini listeye alır, 'iskelet' dilim dağılımını değiştirir. En az bir kural ver; adayın isteği belirsizse ÖNCE soru_sor. Çalışması 20-60 sn sürer; sonuç ok ise liste ekranda yenilenir, sen ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi yaz. 'not' alanına adayın isteğini bir cümleyle özetle.",
parameters: {
type: "object",
properties: {
iller: {
type: "object",
description: "Şehir tercihi farkı. yalniz: listede sadece bu iller; ekle: mevcut illere eklenir; cikar: mevcut illerden düşer. İl adı Türkçe (İstanbul, Ankara…).",
properties: {
yalniz: { type: "array", items: { type: "string" } },
ekle: { type: "array", items: { type: "string" } },
cikar: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
kategoriler: {
type: "object",
description: `İlgi alanı farkı; adlar tam olarak şunlardan: ${KATEGORILER.join(" | ")}.`,
properties: {
yalniz: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
ekle: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
cikar: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
},
additionalProperties: false,
},
universiteTipi: { type: "string", enum: ["devlet", "vakif", "farketmez"], description: "Yalnız devlet / yalnız vakıf / fark etmez." },
iskelet: {
type: "string",
enum: ["standart", "guvenli-agirlikli", "hayal-agirlikli"],
description: "Dilim dağılımı: standart 5 hayal/13 dengeli/6 güvenli; guvenli-agirlikli 4/12/8 ('daha güvenli olsun'); hayal-agirlikli 7/12/5 ('daha iddialı olsun').",
},
ilkTercihler: {
type: "object",
description: "'İlk N tercih şu ölçüte uysun' (ör. ilk 5 İstanbul). En az bir ölçüt ver.",
properties: {
adet: { type: "integer", minimum: 1, maximum: 24 },
iller: { type: "array", items: { type: "string" } },
kategoriler: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
universiteTipi: { type: "string", enum: ["devlet", "vakif"] },
},
required: ["adet"],
additionalProperties: false,
},
cikar: {
type: "object",
description: "Listeye girmeyecekler: üniversite adı parçası, bölüm adı parçası ya da program kimliği (listedeki 'id').",
properties: {
universiteler: { type: "array", items: { type: "string" } },
bolumler: { type: "array", items: { type: "string" } },
programIdler: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
ekle: {
type: "object",
description: "Listeye mutlaka girecek program kimlikleri (program_ara sonucundaki id; uydurma).",
properties: { programIdler: { type: "array", items: { type: "string" } } },
required: ["programIdler"],
additionalProperties: false,
},
not: { type: "string", description: "Adayın isteğinin bir cümlelik özeti (liste üretimine gider)." },
},
additionalProperties: false,
},
},
},
];
// ── Yardımcılar ──────────────────────────────────────────────────────────
@@ -1480,6 +1609,168 @@ function alanHatasi(ad: string, s: AlanSemasi, v: unknown): string | null {
return null;
}
// ── Sohbetten liste revizyonu ────────────────────────────────────────────
/** `soru_sor`: formu denetler, kimlik verir; metin ve kayıt çağıranın (route) işi. */
function soruSor(a: Record<string, unknown>): { soruldu: true; anket: Anket; not: string } | { hata: string } {
const d = anketDogrula(a);
if (!d.ok) return { hata: `Form geçersiz — ${d.hata}. Düzeltip yeniden çağır.` };
return {
soruldu: true,
anket: { id: crypto.randomUUID(), ...d.anket },
not: "Form adaya gösterildi. Şimdi BAŞKA ARAÇ ÇAĞIRMA, formu metin olarak tekrar yazma; tek kısa cümleyle bitir (ör. 'Birkaç kısa soru, sonra listeni yeniden kurayım.'). Cevaplar sonraki mesajda 'Form cevaplarım' olarak gelir.",
};
}
type Normalizasyon = { ok: true; deger: string[] } | { ok: false; hata: string };
function illeriNormalize(ham: unknown, alan: string): Normalizasyon {
if (ham == null) return { ok: true, deger: [] };
if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
const sonuc: string[] = [];
for (const x of ham) {
if (typeof x !== "string") return { ok: false, hata: `${alan}: il adı metin olmalı` };
const il = ilAdiNormalize(x);
if (!il) return { ok: false, hata: `${alan}: "${x.slice(0, 30)}" il olarak tanınmadı; Türkçe il adı yaz (ör. İstanbul, İzmir)` };
if (!sonuc.includes(il)) sonuc.push(il);
}
return { ok: true, deger: sonuc };
}
function kategorileriNormalize(ham: unknown, alan: string): Normalizasyon {
if (ham == null) return { ok: true, deger: [] };
if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
const sonuc: string[] = [];
for (const x of ham) {
if (typeof x !== "string" || !(KATEGORILER as readonly string[]).includes(x)) {
return { ok: false, hata: `${alan}: "${String(x).slice(0, 30)}" geçerli bir ilgi alanı değil; şunlardan biri olmalı: ${KATEGORILER.join(", ")}` };
}
if (!sonuc.includes(x)) sonuc.push(x);
}
return { ok: true, deger: sonuc };
}
function farkNormalize(
ham: unknown,
alan: string,
normalize: (x: unknown, alan: string) => Normalizasyon,
): { ok: true; fark?: ListeFarki } | { ok: false; hata: string } {
if (ham == null) return { ok: true };
if (typeof ham !== "object" || Array.isArray(ham)) return { ok: false, hata: `${alan}: nesne bekleniyor` };
const h = ham as Record<string, unknown>;
const fark: ListeFarki = {};
for (const anahtar of ["yalniz", "ekle", "cikar"] as const) {
if (h[anahtar] == null) continue;
const n = normalize(h[anahtar], `${alan}.${anahtar}`);
if (!n.ok) return n;
if (n.deger.length) fark[anahtar] = n.deger;
}
if (fark.yalniz && fark.yalniz.length > 5) return { ok: false, hata: `${alan}.yalniz: en fazla 5` };
return { ok: true, fark: Object.keys(fark).length ? fark : undefined };
}
function metinListesi(ham: unknown, alan: string, ust = 10): Normalizasyon {
if (ham == null) return { ok: true, deger: [] };
if (!Array.isArray(ham)) return { ok: false, hata: `${alan}: liste bekleniyor` };
const sonuc = ham
.filter((x): x is string => typeof x === "string")
.map((x) => x.replace(/\s+/g, " ").trim().slice(0, 80))
.filter(Boolean);
return { ok: true, deger: [...new Set(sonuc)].slice(0, ust) };
}
/** Modelin argümanını RevizyonKurali'na çevirir; il/alan adları doğrulanır. */
function revizyonKuraliKur(a: Record<string, unknown>): { ok: true; kural: RevizyonKurali } | { ok: false; hata: string } {
const kural: RevizyonKurali = {};
const iller = farkNormalize(a.iller, "iller", illeriNormalize);
if (!iller.ok) return iller;
if (iller.fark) kural.iller = iller.fark;
const kategoriler = farkNormalize(a.kategoriler, "kategoriler", kategorileriNormalize);
if (!kategoriler.ok) return kategoriler;
if (kategoriler.fark) kural.kategoriler = kategoriler.fark;
if (a.universiteTipi != null) kural.universiteTipi = a.universiteTipi as RevizyonKurali["universiteTipi"];
if (a.iskelet != null) kural.iskelet = a.iskelet as RevizyonKurali["iskelet"];
if (a.ilkTercihler != null) {
const o = a.ilkTercihler as Record<string, unknown>;
const adet = Math.round(Number(o.adet));
const il = illeriNormalize(o.iller, "ilkTercihler.iller");
if (!il.ok) return il;
const kat = kategorileriNormalize(o.kategoriler, "ilkTercihler.kategoriler");
if (!kat.ok) return kat;
const tip = o.universiteTipi as "devlet" | "vakif" | undefined;
if (!il.deger.length && !kat.deger.length && !tip) {
return { ok: false, hata: "ilkTercihler: en az bir ölçüt gerekli (iller, kategoriler ya da universiteTipi)" };
}
kural.ilkTercihler = {
adet: Math.min(24, Math.max(1, adet || 1)),
...(il.deger.length ? { iller: il.deger } : {}),
...(kat.deger.length ? { kategoriler: kat.deger } : {}),
...(tip ? { universiteTipi: tip } : {}),
};
}
if (a.cikar != null) {
const c = a.cikar as Record<string, unknown>;
const uni = metinListesi(c.universiteler, "cikar.universiteler");
const bol = metinListesi(c.bolumler, "cikar.bolumler");
const idler = metinListesi(c.programIdler, "cikar.programIdler", 24);
if (!uni.ok) return uni;
if (!bol.ok) return bol;
if (!idler.ok) return idler;
if (uni.deger.length || bol.deger.length || idler.deger.length) {
kural.cikar = {
...(uni.deger.length ? { universiteler: uni.deger } : {}),
...(bol.deger.length ? { bolumler: bol.deger } : {}),
...(idler.deger.length ? { programIdler: idler.deger } : {}),
};
}
}
if (a.ekle != null) {
const idler = metinListesi((a.ekle as Record<string, unknown>).programIdler, "ekle.programIdler", 10);
if (!idler.ok) return idler;
if (idler.deger.length) kural.ekle = { programIdler: idler.deger };
}
if (typeof a.not === "string" && a.not.trim()) kural.not = a.not.replace(/\s+/g, " ").trim().slice(0, 300);
const { not: _not, ...kalan } = kural;
void _not;
if (Object.keys(kalan).length === 0) {
return { ok: false, hata: "en az bir kural gerekli (iller, kategoriler, universiteTipi, iskelet, ilkTercihler, cikar ya da ekle); yalnız 'not' yetmez. İstek belirsizse soru_sor ile sor." };
}
return { ok: true, kural };
}
/**
* `liste_revize`: kuralı doğrular, listeyi yeniden kurdurur
* (features/rapor/liste-revizyon.ts — dinamik import: eval betikleri bu
* modülü Next dışında yükler, server-only bağımlılık orada eager yüklenmesin).
*/
async function listeRevize(b: AracBaglami, a: Record<string, unknown>): Promise<unknown> {
if (!b.hasPaket) {
return { hata: "Liste revizyonu Tercih Dönemi Paketi'ne dâhil; aday paketsiz. Nazikçe söyle: tam liste ve revizyon /paket ile açılır; açık satırlar üzerinden yardımcı olmaya devam et." };
}
if (!b.mesajId) return { hata: "Revizyon bu bağlamda çalıştırılamıyor." };
const kuralSonucu = revizyonKuraliKur(a);
if (!kuralSonucu.ok) return { hata: `Geçersiz kural — ${kuralSonucu.hata}. Düzeltip yeniden çağır.` };
const { listeRevizeCekirdek } = await import("@/features/rapor/liste-revizyon");
const sonuc = await listeRevizeCekirdek({
userId: b.userId,
requestId: b.mesajId,
kural: kuralSonucu.kural,
});
if (!sonuc.ok) {
return {
hata: sonuc.neden,
not:
sonuc.kod === "HAK"
? "Adaya revizyon hakkının dolduğunu söyle; sohbetten listesi hakkında soru sormaya devam edebilir."
: "Adaya listenin yeniden kurulamadığını ve nedenini söyle; 'yeniden kurdum' deme.",
};
}
return {
...sonuc.ozet,
not: "Liste ekranda yenilendi. Adaya ne değiştiğini 2-3 cümleyle söyle ve ilk 5 tercihi numaralı yaz; kalan revizyon hakkını belirt. Sayı uydurma; yukarıdaki alanları kullan.",
};
}
/**
* Argümanı aracın ARACLAR'daki şemasına göre denetler: bilinmeyen alan,
* yanlış tip, geçersiz seçenek, alt sınır, eksik zorunlu alan. Hata varsa
@@ -1548,6 +1839,10 @@ export function aracCalistirici(b: AracBaglami) {
return rehberOku(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
case "soru_sor":
return soruSor(a);
case "liste_revize":
return listeRevize(b, a);
default:
return { hata: `Bilinmeyen araç: ${ad}` };
}

View File

@@ -107,6 +107,25 @@ export function kayitBeyaniVar(metin: string): boolean {
export const KAYIT_DUZELTME_NOTU =
"Düzeltme: Bu cevapta profiline yeni bir kayıt YAPILMADI; yukarıda \"kaydettim\" dediysem henüz kaydedemedim. Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz.)";
/**
* Cevap "listeni yeniden kurdum / revize ettim" türü bir beyan içeriyor mu.
* Revizyon yalnız liste_revize aracıyla olur (7 Eki 2026); model aracı
* çağırmadan "güncelledim" diyebilir. Olumsuzlar ve sorular eşleşmez.
*/
const REVIZE_BEYANI =
/(?<!\p{L})(?:listeni (?:yeniden kurdum|güncelledim|revize ettim|değiştirdim|yeniledim)|liste(?:n)? (?:yeniden kuruldu|güncellendi|revize edildi|yenilendi)|revizyonu(?:nu)? (?:yaptım|tamamladım|uyguladım)|yeniden kurdum)(?!\p{L})/u;
const REVIZE_REDDI =
/(?<!\p{L})(?:kuramadım|kurulamadı|güncelleyemedim|güncellenemedi|revize edemedim|değiştiremedim|yenileyemedim|kurmadım|değiştirmedim)(?!\p{L})/u;
export function revizeBeyaniVar(metin: string): boolean {
const m = tr(metin);
return REVIZE_BEYANI.test(m) && !REVIZE_REDDI.test(m);
}
/** Cevap revizyon bildiriyor ama o turda başarılı liste_revize yoksa sonuna eklenir. */
export const REVIZE_DUZELTME_NOTU =
"Düzeltme: Bu cevapta listen YENİDEN KURULMADI; yukarıda \"yeniden kurdum\" dediysem henüz yapmadım. Ne değişsin istediğini bir cümleyle yazarsan listeni ona göre yeniden kurarım.";
/**
* İç şema sızıntısı belirtisi: cevapta araçların iç adlarından en az üçü ya
* da JSON şema anahtarları geçiyor (5 Eki 2026 eval s077: 11 aracın adı ve

View File

@@ -13,6 +13,8 @@ import { PUAN_TURLERI } from "@/types/yokatlas";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { adayDurumu, ozelDurumOzeti } from "@/lib/ozel-durum";
import { ONCEKI_YIL, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { kuralOzeti } from "@/lib/revizyon-kurali";
import { KATEGORILER } from "@/lib/kategoriler";
/**
* Profil kaydı öncesi "kaydedeyim mi?" onayı (KVKK Üretken YZ Rehberi:
@@ -64,6 +66,49 @@ programda baraj yok ya da veride yok — baraj UYDURMA. Ön lisans (TYT)
programlarında başarı sırası barajı yoktur.`;
}
/**
* Sohbetten liste revizyonu kuralları (7 Eki 2026). Eski "revize et" kutusu
* kaldırıldı; aday listesinde değişiklik istediğinde danışman önce isteği
* netleştirir (soru_sor formu), sonra liste_revize ile YAPISAL kural verir.
* Serbest metin kural değildir: "ilk 5 İstanbul olsun" → ilkTercihler.
*/
function revizyonBolumu(r: { kalanHak: number; paketli: boolean }, params: RaporParams): string {
const mevcut = kuralOzeti(params.revizyon);
const durum = !r.paketli
? `Adayın paketi YOK: liste_revize çağırma; listeyi değiştirmek isterse tam liste ve revizyonun Tercih Dönemi Paketi'yle (/paket) açıldığını sempatik biçimde söyle.`
: r.kalanHak <= 0
? `Adayın revizyon hakkı DOLDU (${r.kalanHak} kaldı): liste_revize çağırma; değişiklik isterse hakkın dolduğunu söyle, listesi hakkında soru cevaplamaya devam et.`
: `Adayın kalan revizyon hakkı: ${r.kalanHak}. Her başarılı liste_revize bir hak düşer; bu yüzden isteği tek seferde doğru kur.`;
return `LİSTE REVİZYONU (sohbetten): aday listesinde değişiklik istediğinde ("ilk 5
İstanbul olsun", "Ankara da olsun", "vakıf olmasın", "X üniversitesini çıkar",
"daha güvenli olsun", "şu programı ekle") şu sırayla çalış:
1. İstek NET ve tam ise (il adı belli, kaç tercih belli, ekle mi yalnız mı
belli) doğrudan liste_revize çağır. Karşılıklar: "ilk N tercih X ilinden"
→ ilkTercihler {adet: N, iller: [X]} (X sihirbaz illerinde yoksa iller.ekle
ile de ekle); "sadece X" → iller.yalniz; "X de olsun" → iller.ekle; "X
olmasın" → iller.cikar; "sadece devlet / vakıf olmasın" → universiteTipi;
"daha güvenli / garantili olsun" → iskelet guvenli-agirlikli; "daha iddialı"
→ iskelet hayal-agirlikli; "X üniversitesi / Y bölümü olmasın" → cikar; "şu
programı ekle" → önce program_ara ile kimliği bul, sonra ekle.programIdler.
İlgi alanı adları tam olarak şunlardan biri olmalı: ${KATEGORILER.join(", ")}.
2. İstek BELİRSİZ ise (il söylenmemiş, "ilk kaç" belli değil, ekle mi yalnız mı
belli değil, "daha iyi olsun" gibi) ÖNCE soru_sor ile 1-3 kısa soru sor;
seçenekleri adayın durumuna göre yaz (ör. sihirbaz illeri + "Başka il").
Form açıldıktan sonra başka araç çağırma, formu metinle tekrarlama, tek
kısa cümleyle bitir. Cevap "Form cevaplarım:" diye gelince liste_revize
çağır; tekrar sorma.
3. liste_revize ok dönünce: liste ekranda zaten yenilendi. Ne değiştiğini 2-3
cümleyle söyle, ilk 5 tercihi numaralı yaz (aracın verdiği adlarla), kalan
hakkı belirt. Dilim sırası "ilk N" kuralında bilerek bozulur; aday sorarsa
bunu söyle. Araç hata dönerse "yeniden kurdum" DEME; nedenini söyle.
4. REVİZYON DÜRÜSTLÜĞÜ: "listeni yeniden kurdum / güncelledim / revize ettim"
cümlesini ancak BU cevapta liste_revize çağırdıysan ve ok döndüyse yaz.
Sihirbaz seçimlerini (sıra, alan) değiştirmek bu aracın işi değil: aday
sırasını ya da alanını değiştirmek isterse "Tercihlerimi düzenle"
düğmesine yönlendir.
${durum}${mevcut.length ? `\nUYGULANMIŞ REVİZYON KURALLARI (önceki revizyonlardan, hâlâ geçerli): ${mevcut.join("; ")}.` : ""}`;
}
export function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
@@ -77,6 +122,11 @@ export function sistemPromptu(opts: {
* değerlendirme verilmez (liste gizli); uyarılar herkese.
*/
rapor?: { genelDegerlendirme?: string; uyarilar?: string[]; kapsam?: { toplam: number; uyarlandi: boolean } };
/**
* Sohbetten liste revizyonu (7 Eki 2026): kalan hak ve paket durumu.
* Verilmezse revizyon bölümü yazılmaz (eval betikleri eski davranışı korur).
*/
revizyon?: { kalanHak: number; paketli: boolean };
}): string {
const { params } = opts;
const secimVar =
@@ -173,8 +223,9 @@ KURALLAR:
Kendi genel bilginle kural, oran, tutar, katsayı ya da tarih YAZMA. Yazı
bulunmazsa "rehberimizde bu konuda yazı yok" de ve ÖSYM kılavuzuna ya da
ilgili kuruma yönlendir.
- ARAÇ KURALLARI: araçlar yalnız okur; 24'lük listeyi araçla kurmazsın,
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
- ARAÇ KURALLARI: sorgu araçları yalnız okur; 24'lük listeyi kendin
KURMAZSIN, satır ekleyip çıkarmazsın — listeyi değiştiren tek yol
liste_revize aracıdır (aşağıda). Araç sonucundaki sayıları olduğu gibi
aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır
(sınır: bir adımda 4, bir cevapta 8 çağrı; fazlası çalıştırılmaz).
Araç çağırmadan ÖNCE yazdığın metin ("İşte en iyi 10 üniversite:")
@@ -271,6 +322,7 @@ KURALLAR:
// TYT sırası lisans barajlarıyla kıyaslanmaz; ön lisansta baraj yok
const baraj = params.tur === "tyt" ? null : barajBolumu(params.sira, opts.barajlar);
if (baraj) parcalar.splice(2, 0, baraj);
if (opts.revizyon) parcalar.push(revizyonBolumu(opts.revizyon, params));
// Raporun üst metni: adayın listesini kuran yapay zekânın genel
// değerlendirmesi ve uyarıları — danışman "listem nasıl kuruldu?" sorusunu
// kendi raporundan cevaplar. Kilitli kullanıcıda genel değerlendirme gizli.

View File

@@ -24,7 +24,6 @@ import {
havuzuKompaktJson,
turLabel,
dilimHedefiHesapla,
idealHedef,
IDEAL_HEDEF,
type DilimHedef,
type Dilim,
@@ -40,6 +39,14 @@ import {
} from "../risk";
import { trendOzetiMetni } from "../trend";
import { ILK_YIL } from "../veri-yillari";
import {
cikarSuzgeci,
efektifParams,
ilkTercihleriOneAl,
kuralOzeti,
oncelikSuzgeci,
revizyonIskeleti,
} from "../revizyon-kurali";
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
@@ -364,7 +371,7 @@ function mockRapor(
revizyonNotu?: string,
): RaporSonuc {
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const efektif = (p: AdayProgram) => p.efektifSira ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
@@ -407,13 +414,17 @@ function mockRapor(
"(Dev mock) Güvenli dilimindeki son tercihleri asla silme.",
],
programlar,
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
devMock: true,
};
}
/** Modele giden havuz: dilim başına üç dizi; satırda `dilim` alanı yok (dizinin adı dilimdir). */
function havuzuDilimliJson(havuz: AdayProgram[]): string {
/** Modele giden havuz: dilim başına üç dizi; satırda `dilim` alanı yok (dizinin adı dilimdir).
* `oncelikli`: revizyonun "ilk N" ölçütüne uyan satırlar işaretlenir (yalnız kural varken). */
function havuzuDilimliJson(
havuz: AdayProgram[],
oncelikli?: ((p: AdayProgram) => boolean) | null,
): string {
const satirlar = JSON.parse(havuzuKompaktJson(havuz)) as ({
id: string;
dilim: Dilim;
@@ -423,7 +434,11 @@ function havuzuDilimliJson(havuz: AdayProgram[]): string {
dengeli: [],
guvenli: [],
};
for (const { dilim, ...kalan } of satirlar) gruplar[TEL_ADI[dilim]].push(kalan);
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
for (const { dilim, ...kalan } of satirlar) {
const p = oncelikli ? havuzMap.get(kalan.id as string) : undefined;
gruplar[TEL_ADI[dilim]].push(p && oncelikli!(p) ? { ...kalan, oncelikli: true } : kalan);
}
return JSON.stringify(gruplar);
}
@@ -482,7 +497,17 @@ export async function raporUretAyrintili(
oncekiListeOzeti?: string,
profil?: DanismanProfili,
): Promise<{ rapor: RaporSonuc; tani: RaporTani | null }> {
const { havuz, gevsetildi } = havuzOlustur(params, profil);
// Sohbet revizyonu (revizyon-kurali.ts): il/alan/tip farkı sihirbaz seçiminin
// üstüne biner, "çıkar" havuza girmez, "ilk N" ölçütü ve "ekle" kimlikleri
// kırpmada öne alınır. Kayıtlı params değişmez; yalnız üretim bu kopyayı görür.
const kural = params.revizyon;
const havuzParams = efektifParams(params);
const oncelikli = oncelikSuzgeci(kural);
const { havuz, gevsetildi } = havuzOlustur(havuzParams, profil, {
cikar: cikarSuzgeci(kural) ?? undefined,
oncelikli: oncelikli ?? undefined,
ekstraIdler: kural?.ekle?.programIdler,
});
const profilSatir = profil ? profilSatirlari(profil) : [];
if (havuz.length < MIN_LISTE) {
throw new RaporUretimHatasi(
@@ -494,15 +519,17 @@ export async function raporUretAyrintili(
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
// üretim her seferinde doğrulamada patlıyordu. İskelet "Kolay yerleşme"
// seçiliyse 4/12/8 (idealHedef).
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.ozelDurum?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
(havuzParams.kategoriler?.length ?? 0) > 0 ||
(havuzParams.iller?.length ?? 0) > 0 ||
(havuzParams.oncelikler?.length ?? 0) > 0 ||
(havuzParams.ozelDurum?.length ?? 0) > 0 ||
(havuzParams.universiteTipi && havuzParams.universiteTipi !== "farketmez");
const kuralSatirlari = kuralOzeti(kural);
const ilkN = kural?.ilkTercihler;
const gevsetmeNotu: string[] = [];
if (gevsetildi.includes("profil")) {
@@ -530,13 +557,20 @@ export async function raporUretAyrintili(
const kullaniciMesaji = [
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
secimVar ? `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
kategoriler: params.kategoriler ?? [],
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
ozelDurum: params.ozelDurum ?? [],
kategoriler: havuzParams.kategoriler ?? [],
iller: havuzParams.iller ?? [],
universiteTipi: havuzParams.universiteTipi ?? "farketmez",
oncelikler: havuzParams.oncelikler ?? [],
ozelDurum: havuzParams.ozelDurum ?? [],
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
: null,
kuralSatirlari.length > 0
? `ADAYIN SOHBETTE İSTEDİĞİ REVİZYON KURALLARI (koddan uygulandı; havuz buna göre kuruldu):\n${kuralSatirlari.map((k) => `- ${k}`).join("\n")}${
ilkN && oncelikli
? `\nHavuzda "oncelikli": true olan satırlar "ilk ${ilkN.adet}" ölçütüne uyar: hayal ve dengeli listelerinde bunlardan toplam EN AZ ${ilkN.adet} tane seç; kod bunları listenin başına alacak.`
: ""
}`
: null,
params.notlar
? `Adayın ek notları: <aday_notu>${kullaniciMetni(params.notlar)}</aday_notu>`
: null,
@@ -547,7 +581,7 @@ export async function raporUretAyrintili(
revizyonNotu
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: <revizyon_istegi>${kullaniciMetni(revizyonNotu)}</revizyon_istegi>\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
: null,
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz)}`,
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz, oncelikli)}`,
]
.filter(Boolean)
.join("\n\n");
@@ -558,11 +592,12 @@ export async function raporUretAyrintili(
return p.efektifSira ?? Number.MAX_SAFE_INTEGER;
};
// Adayın seçimine uyum (tamamlama önceliği ve şablon gerekçe için)
const seciliKategoriler = params.kategoriler ?? [];
// Adayın seçimine uyum (tamamlama önceliği ve şablon gerekçe için);
// revizyonla değişen il/alan da buradan okunur
const seciliKategoriler = havuzParams.kategoriler ?? [];
const seciliIller = [
...(params.il ? [params.il] : []),
...(params.iller ?? []),
...(havuzParams.il ? [havuzParams.il] : []),
...(havuzParams.iller ?? []),
];
const alanUyar = (p: AdayProgram) =>
seciliKategoriler.length > 0 &&
@@ -571,7 +606,10 @@ export async function raporUretAyrintili(
seciliIller.length > 0 && p.il != null && seciliIller.includes(p.il);
// Alan 2, il 1, uyum puanı 0-1: tamamlama ve kırpma önce seçime uyana, eşitte
// uyum puanı yüksek olana gider
const uyum = (p: AdayProgram) => (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
// Revizyonun "ilk N" ölçütüne uyan program her şeyin önünde (+4): kırpma ve
// tamamlama adayın açık isteğini korur
const uyum = (p: AdayProgram) =>
(oncelikli?.(p) ? 4 : 0) + (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
// Onarım modelin seçiminin en az yarısını koruyabiliyorsa ikinci çağrı
// yapılmaz; altındaysa liste çoğunlukla kodla dolardı, model bir kez daha denenir.
@@ -808,7 +846,7 @@ export async function raporUretAyrintili(
// Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
const tercihler = DILIMLER.flatMap((dilim) =>
const dilimSirali = DILIMLER.flatMap((dilim) =>
[...nihai.secim[dilim]]
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
.map((t) => ({ dilim, satir: t })),
@@ -824,13 +862,15 @@ export async function raporUretAyrintili(
...(p.oneCikan?.length ? { oneCikan: p.oneCikan } : {}),
};
});
// Revizyonun "ilk N" kuralı: ölçüte uyanlar başa (revizyon-kurali.ts)
const { tercihler } = ilkTercihleriOneAl(dilimSirali, kural, (id) => havuzMap.get(id));
return {
rapor: {
tercihler,
genelDegerlendirme: genel,
uyarilar,
programlar,
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
},
tani: {
kaynak,

View File

@@ -24,7 +24,14 @@ import {
} from "ai";
import { ARACLAR, aracSeti, type AracBaglami } from "./araclar";
import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
import { KAYIT_DUZELTME_NOTU, kayitBeyaniVar, semaSizintisiVar, vaatDiliBul } from "./cikti-denetimi";
import {
KAYIT_DUZELTME_NOTU,
REVIZE_DUZELTME_NOTU,
kayitBeyaniVar,
revizeBeyaniVar,
semaSizintisiVar,
vaatDiliBul,
} from "./cikti-denetimi";
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
@@ -40,6 +47,12 @@ const ADIM_BASINA_ARAC = 4;
const CEVAP_BASINA_ARAC = 8;
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
const ARAC_ADLARI = ARACLAR.flatMap((a) => (a.type === "function" ? [a.function.name] : []));
/**
* liste_revize liste üretimini çalıştırır (iki model denemesine kadar, ~20-60
* sn); sohbetin 120 sn'lik toplam süresi ona yetmeyebilir. Araç başlayınca
* zaman aşımı bu kadar ileri alınır (features/rapor/liste-revizyon.ts).
*/
const REVIZYON_EK_SURE_MS = 150_000;
export type CiktiDenetimi = {
/** Jev'in ihlal olasılığı; denetim yapılamadıysa null. */
@@ -52,6 +65,8 @@ export type CiktiDenetimi = {
reddedilenArac: number;
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
kayitNotu: boolean;
/** "Yeniden kurdum" dendi ama başarılı liste_revize yoktu; düzeltme notu eklendi. */
revizeNotu: boolean;
/** Cevapta araç adları / şema anahtarları var (yalnız gözlem). */
semaSizintisi: boolean;
};
@@ -68,6 +83,11 @@ export function danismanAkisi(opts: {
zamanAsimiMs?: number;
/** Çıktı denetiminin sonucu (gözlem/eval için; cevabı etkilemez). */
onDenetim?: (d: CiktiDenetimi) => void;
/**
* Her başarılı araç sonucu (ad, çıktı). Route bununla soru formunu ve
* revizyon sonucunu kayda (chat_messages.meta) yazar.
*/
onAracSonucu?: (ad: string, cikti: unknown) => void;
}): ReadableStream<UIMessageChunk> {
return createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
@@ -76,12 +96,19 @@ export function danismanAkisi(opts: {
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
let akisHatasi: string | null = null;
// Sağlayıcı takılırsa SDK akışı "abort" parçasıyla sessizce bitirir
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır.
const zamanAsimi = AbortSignal.timeout(opts.zamanAsimiMs ?? 120_000);
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır. Süre
// uzatılabilir olsun diye AbortSignal.timeout yerine zamanlayıcı.
const kesici = new AbortController();
let sure = opts.zamanAsimiMs ?? 120_000;
let zamanlayici = setTimeout(() => kesici.abort(), sure);
const zamanAsimi = kesici.signal;
let adimdakiArac = 0;
let toplamArac = 0;
let reddedilenArac = 0;
let basariliKayit = 0;
let basariliRevizyon = 0;
// soru_sor çağrıldı: form adayda; model aynı cevapta başka araç çağırmasın
let formAcildi = false;
try {
const result = streamText({
model: sohbetDilModeli(),
@@ -89,19 +116,36 @@ export function danismanAkisi(opts: {
messages: opts.mesajlar,
tools: aracSeti(
opts.aracBaglami,
(ad) => console.info("[soru] araç:", ad),
() => {
(ad) => {
console.info("[soru] araç:", ad);
if (ad === "liste_revize") {
clearTimeout(zamanlayici);
sure += REVIZYON_EK_SURE_MS;
zamanlayici = setTimeout(() => kesici.abort(), sure);
}
},
(ad) => {
adimdakiArac++;
toplamArac++;
if (formAcildi) {
reddedilenArac++;
return "Soru formu adayda; cevap gelene kadar başka araç çağrılmaz. Bu çağrı ÇALIŞTIRILMADI. Tek kısa cümleyle bitir.";
}
if (ad === "liste_revize" && basariliRevizyon > 0) {
reddedilenArac++;
return "Liste bu cevapta zaten yeniden kuruldu; ikinci revizyon ÇALIŞTIRILMADI. Sonucu adaya anlat.";
}
if (adimdakiArac <= ADIM_BASINA_ARAC && toplamArac <= CEVAP_BASINA_ARAC) return null;
reddedilenArac++;
return `Araç çağrısı sınırı aşıldı (bir adımda en fazla ${ADIM_BASINA_ARAC}, bir cevapta en fazla ${CEVAP_BASINA_ARAC}). Bu çağrı ÇALIŞTIRILMADI. Yeni araç çağırma; elindeki sonuçlarla cevap ver, eksik kalan kısmı adaya açıkça söyle ve sorusunu daraltmasını iste. Çalışmayan çağrı için sayı uydurma.`;
},
(ad, cikti) => {
const nesne = cikti != null && typeof cikti === "object" ? (cikti as Record<string, unknown>) : null;
// Başarılı kayıt = profil_guncelle "kaydedildi" döndü (hata dönerse sayılmaz)
if (ad === "profil_guncelle" && cikti != null && typeof cikti === "object" && "kaydedildi" in cikti) {
basariliKayit++;
}
if (ad === "profil_guncelle" && nesne && "kaydedildi" in nesne) basariliKayit++;
if (ad === "liste_revize" && nesne?.ok === true) basariliRevizyon++;
if (ad === "soru_sor" && nesne?.soruldu === true) formAcildi = true;
if (nesne && !("hata" in nesne)) opts.onAracSonucu?.(ad, cikti);
},
),
// En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
@@ -109,7 +153,8 @@ export function danismanAkisi(opts: {
stopWhen: isStepCount(ARAC_TUR_SINIRI),
prepareStep: ({ stepNumber }) => {
adimdakiArac = 0;
return stepNumber >= ARAC_TUR_SINIRI - 1 ? { toolChoice: "none" } : undefined;
// Form açıldıysa model cevabı bitirmek zorunda (araç kapalı)
return stepNumber >= ARAC_TUR_SINIRI - 1 || formAcildi ? { toolChoice: "none" } : undefined;
},
maxOutputTokens: 1024,
abortSignal: zamanAsimi,
@@ -184,11 +229,22 @@ export function danismanAkisi(opts: {
writer.write({ type: "text-delta", id, delta: KAYIT_DUZELTME_NOTU });
writer.write({ type: "text-end", id });
}
// Revizyon beyanı denetimi: "yeniden kurdum" ama bu turda başarılı
// liste_revize yok → düzeltme notu (kayıt denetimiyle aynı mantık).
const revizeNotu = basariliRevizyon === 0 && revizeBeyaniVar(metin);
if (revizeNotu) {
console.info("[soru] revizyon beyanı var, başarılı liste_revize yok → not eklendi");
const id = crypto.randomUUID();
metin += `\n\n${REVIZE_DUZELTME_NOTU}`;
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: REVIZE_DUZELTME_NOTU });
writer.write({ type: "text-end", id });
}
// İç şema sızıntısı: yalnız gözlem (cevap geri alınamaz). Metin loglanmaz.
const semaSizintisi = semaSizintisiVar(metin, ARAC_ADLARI);
if (semaSizintisi) console.warn("[soru] olası iç şema sızıntısı: cevapta araç adları / şema anahtarları var");
if (reddedilenArac) console.info("[soru] araç sınırı:", reddedilenArac, "çağrı çalıştırılmadı");
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, semaSizintisi });
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, revizeNotu, semaSizintisi });
await opts.onTamam(metin);
tamamlandi = true;
writer.write({ type: "finish" });
@@ -199,6 +255,8 @@ export function danismanAkisi(opts: {
// Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa
// (boş cevap, kayıt hatası) istemci hatayı buradan görür.
if (akisHatasi == null) throw err;
} finally {
clearTimeout(zamanlayici);
}
},
onError: () => HATA_METNI,

192
src/lib/ai/sohbet-meta.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* Danışman sohbetinin yapısal mesaj ekleri (chat_messages.meta).
*
* Sohbet kaydı düz metindir; iki tur bunun dışına taşar ve burada tiplenir:
* - `anket`: danışmanın `soru_sor` aracıyla adaya gösterdiği soru formu
* (lib/ai/araclar.ts). Ekranda shadcn Questionnaire ile çizilir
* (features/rapor/components/sohbet-anketi.tsx); sayfa yenilenince de aynı
* form kayıttan geri gelir.
* - `anketCevabi`: adayın forma verdiği cevaplar (kullanıcı mesajı). Bu mesaj
* kredi harcamaz (api/soru/route.ts: formun cevabı, profil onayı gibi
* bedelsizdir).
* - `revizyon`: `liste_revize` aracının sonucu (liste yeniden kuruldu / kurulamadı).
*
* Saf modül: DB'ye ve Next'e dokunmaz; istemci ve sunucu birlikte okur.
*/
export type AnketSecenek = {
deger: string;
etiket: string;
aciklama?: string;
};
export type AnketSoru = {
/** Form alanı adı (cevap anahtarı); harf, rakam, tire. */
ad: string;
soru: string;
aciklama?: string;
secenekler: AnketSecenek[];
/** Birden çok seçenek işaretlenebilir. */
coklu?: boolean;
/** Serbest metin cevabı da alınır; değer yer tutucu metindir. */
serbest?: string;
/** Boş geçilemez (varsayılan: zorunlu). */
zorunlu?: boolean;
};
export type Anket = {
id: string;
baslik?: string;
sorular: AnketSoru[];
};
export type AnketCevaplari = Record<string, string | string[]>;
export type RevizyonOzeti =
| {
ok: true;
toplam: number;
dagilim: { hayal: number; dengeli: number; garanti: number };
kalanHak: number;
ilkTercihler: string[];
}
| { ok: false; neden: string };
export type SohbetMeta = {
anket?: Anket;
anketCevabi?: { anketId: string; cevaplar: AnketCevaplari };
revizyon?: RevizyonOzeti;
};
export const ANKET_EN_COK_SORU = 3;
export const ANKET_EN_COK_SECENEK = 6;
const AD_KALIBI = /^[a-z0-9-]{1,32}$/;
const METIN_UST = 160;
function kisa(s: unknown, ust = METIN_UST): string | null {
if (typeof s !== "string") return null;
const m = s.replace(/\s+/g, " ").trim();
return m ? m.slice(0, ust) : null;
}
/**
* Modelin `soru_sor` argümanını denetleyip temizler; hata varsa metin döner
* (model düzeltip yeniden çağırabilsin). Soru ve seçenek sayısı sınırlı: form
* sohbet balonunda tek tek gösterilir, uzun anket sohbeti boğar.
*/
export function anketDogrula(
girdi: unknown,
): { ok: true; anket: Omit<Anket, "id"> } | { ok: false; hata: string } {
if (typeof girdi !== "object" || girdi === null) return { ok: false, hata: "nesne bekleniyor" };
const g = girdi as Record<string, unknown>;
const baslik = kisa(g.baslik, 80) ?? undefined;
if (!Array.isArray(g.sorular) || g.sorular.length === 0) {
return { ok: false, hata: "sorular: en az bir soru gerekli" };
}
if (g.sorular.length > ANKET_EN_COK_SORU) {
return { ok: false, hata: `sorular: en fazla ${ANKET_EN_COK_SORU} soru` };
}
const sorular: AnketSoru[] = [];
const adlar = new Set<string>();
for (const [i, ham] of g.sorular.entries()) {
if (typeof ham !== "object" || ham === null) return { ok: false, hata: `sorular[${i}]: nesne bekleniyor` };
const s = ham as Record<string, unknown>;
const ad = typeof s.ad === "string" ? s.ad.trim().toLocaleLowerCase("en-US") : "";
if (!AD_KALIBI.test(ad)) return { ok: false, hata: `sorular[${i}].ad: yalnız küçük harf, rakam ve tire (en çok 32)` };
if (adlar.has(ad)) return { ok: false, hata: `sorular[${i}].ad: "${ad}" tekrar ediyor` };
adlar.add(ad);
const soru = kisa(s.soru);
if (!soru) return { ok: false, hata: `sorular[${i}].soru: boş` };
if (!Array.isArray(s.secenekler) || s.secenekler.length < 2) {
return { ok: false, hata: `sorular[${i}].secenekler: en az 2 seçenek` };
}
if (s.secenekler.length > ANKET_EN_COK_SECENEK) {
return { ok: false, hata: `sorular[${i}].secenekler: en fazla ${ANKET_EN_COK_SECENEK} seçenek` };
}
const secenekler: AnketSecenek[] = [];
const degerler = new Set<string>();
for (const [j, sh] of s.secenekler.entries()) {
if (typeof sh !== "object" || sh === null) return { ok: false, hata: `sorular[${i}].secenekler[${j}]: nesne bekleniyor` };
const o = sh as Record<string, unknown>;
const deger = kisa(o.deger, 64);
const etiket = kisa(o.etiket, 80);
if (!deger || !etiket) return { ok: false, hata: `sorular[${i}].secenekler[${j}]: deger ve etiket gerekli` };
if (degerler.has(deger)) return { ok: false, hata: `sorular[${i}].secenekler[${j}].deger: "${deger}" tekrar ediyor` };
degerler.add(deger);
const aciklama = kisa(o.aciklama, 120) ?? undefined;
secenekler.push({ deger, etiket, ...(aciklama ? { aciklama } : {}) });
}
const aciklama = kisa(s.aciklama) ?? undefined;
const serbest = kisa(s.serbest, 80) ?? undefined;
sorular.push({
ad,
soru,
...(aciklama ? { aciklama } : {}),
secenekler,
...(s.coklu === true ? { coklu: true } : {}),
...(serbest ? { serbest } : {}),
...(s.zorunlu === false ? { zorunlu: false } : {}),
});
}
return { ok: true, anket: { ...(baslik ? { baslik } : {}), sorular } };
}
/**
* İstemciden gelen cevapları forma göre süzer: bilinmeyen alan atılır, seçenek
* dışı değer yalnız `serbest` sorularda kabul edilir, boş cevap düşer.
*/
export function anketCevabiDogrula(anket: Anket, ham: unknown): AnketCevaplari | null {
if (typeof ham !== "object" || ham === null) return null;
const h = ham as Record<string, unknown>;
const cevaplar: AnketCevaplari = {};
for (const soru of anket.sorular) {
const v = h[soru.ad];
const secenek = new Set(soru.secenekler.map((s) => s.deger));
const temiz = (x: unknown): string | null => {
const m = kisa(x, 200);
if (!m) return null;
if (secenek.has(m)) return m;
return soru.serbest ? m : null;
};
if (soru.coklu) {
const liste = (Array.isArray(v) ? v : [v]).map(temiz).filter((x): x is string => x != null);
if (liste.length) cevaplar[soru.ad] = [...new Set(liste)];
} else {
const tek = temiz(Array.isArray(v) ? v[0] : v);
if (tek) cevaplar[soru.ad] = tek;
}
}
return cevaplar;
}
function secenekEtiketi(soru: AnketSoru, deger: string): string {
return soru.secenekler.find((s) => s.deger === deger)?.etiket ?? deger;
}
/**
* Cevapların okunur hâli: adayın balonunda ve modele giden mesajda aynı metin
* ("Hangi il? → İstanbul · Kaç tercih? → İlk 5").
*/
export function anketCevabiMetni(anket: Anket, cevaplar: AnketCevaplari): string {
const satirlar = anket.sorular.map((soru) => {
const v = cevaplar[soru.ad];
if (v == null || (Array.isArray(v) && v.length === 0)) return `${soru.soru} → (boş geçtim)`;
const etiket = Array.isArray(v)
? v.map((d) => secenekEtiketi(soru, d)).join(", ")
: secenekEtiketi(soru, v);
return `${soru.soru} → ${etiket}`;
});
return `Form cevaplarım:\n${satirlar.map((s) => `- ${s}`).join("\n")}`;
}
/**
* Formun modele giden kısa dökümü: kayıtlı asistan metni formu içermez; model
* cevapları neye verildiğini buradan görür (api/soru/route.ts model penceresi).
*/
export function anketModelMetni(anket: Anket): string {
const satirlar = anket.sorular.map(
(s) =>
`- ${s.ad}: "${s.soru}" (${s.secenekler.map((o) => `${o.deger} = ${o.etiket}`).join("; ")}${s.serbest ? "; serbest metin de olabilir" : ""})`,
);
return `[Adaya gösterilen soru formu${anket.baslik ? ` — ${anket.baslik}` : ""}:\n${satirlar.join("\n")}]`;
}

View File

@@ -1,3 +1,4 @@
import type { SohbetMeta } from "@/lib/ai/sohbet-meta";
import {
sqliteTable,
text,
@@ -145,6 +146,9 @@ export const chatMessages = sqliteTable(
role: text("role", { enum: ["user", "assistant"] }).notNull(),
content: text("content").notNull(),
clientMessageId: text("client_message_id").unique(),
// Yapısal ek (7 Eki 2026): soru formu, form cevabı, liste revizyon sonucu
// (lib/ai/sohbet-meta.ts). Düz metin mesajda null.
meta: text("meta", { mode: "json" }).$type<SohbetMeta>(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],

View File

@@ -1,5 +1,6 @@
import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import type { SohbetMeta } from "./ai/sohbet-meta";
const { user, creditLedger, chatMessages } = schema;
@@ -99,6 +100,7 @@ export async function spendCreditForMessage(opts: {
userId: string;
content: string;
clientMessageId: string;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
@@ -117,6 +119,7 @@ export async function spendCreditForMessage(opts: {
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
meta: opts.meta ?? null,
createdAt: new Date(),
});
await tx.insert(creditLedger).values({
@@ -138,14 +141,16 @@ export async function spendCreditForMessage(opts: {
}
/**
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — yalnız profil onayı ("kaydedeyim
* mi?" → "evet") için (Bilal kararı, 26 Eyl 2026: onay ücretsiz). Ledger'a
* satır yazılmaz; clientMessageId çakışırsa DUPLICATE.
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — profil onayı ("kaydedeyim mi?"
* → "evet"; Bilal kararı, 26 Eyl 2026: onay ücretsiz) ve danışmanın soru
* formuna verilen cevap (7 Eki 2026) için. Ledger'a satır yazılmaz;
* clientMessageId çakışırsa DUPLICATE.
*/
export async function bedelsizMesajKaydet(opts: {
userId: string;
content: string;
clientMessageId: string;
meta?: SohbetMeta;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
@@ -155,6 +160,7 @@ export async function bedelsizMesajKaydet(opts: {
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
meta: opts.meta ?? null,
createdAt: new Date(),
});
return { ok: true, messageId };

48
src/lib/hareket.ts Normal file
View File

@@ -0,0 +1,48 @@
// Arc UI'dan (uiarc.dev) taşınan bileşenlerin ortak hareket sabitleri.
// Arc'ın `motionTokens`'ının DESIGN.md §6'ya göre ayarlı hâli: yaylar
// sekmesizdir; sekme yalnız kullanıcı bir şeyi bıraktığında/fırlattığında
// (`birakma`) vardır. Yeni bir Arc bileşeni taşınırken yay buradan alınır.
export const YAY = {
/** §6 varsayılanı: vurgu kayması, sayı rulosu, panel geçişi. */
varsayilan: { type: "spring", bounce: 0, duration: 0.35 },
/** Panel, yükseklik, yığın yerleşimi. Arc "smooth". */
akici: { type: "spring", visualDuration: 0.4, bounce: 0 },
/** Genişlik/biçim dönüşümü (çip, yazı). Arc "morph", sekmesi alınmış. */
donusum: { type: "spring", visualDuration: 0.42, bounce: 0 },
/** Parmak bıraktıktan sonraki yerine oturma. Arc "snappy": tek yerde hafif sekme. */
birakma: { type: "spring", visualDuration: 0.26, bounce: 0.12 },
} as const;
type Egri = [number, number, number, number];
export const EGRI: { giris: Egri; standart: Egri } = {
/** Ekrana giren öğe: hızlı başlar, yumuşak durur. */
giris: [0.16, 1, 0.3, 1],
/** Ekrandayken değişen renk/opaklık. */
standart: [0.22, 1, 0.36, 1],
};
export const SURE = { anlik: 0.12, hizli: 0.16, cikis: 0.18, standart: 0.24 } as const;
/**
* Sınırın ötesine çekilen öğenin direnci (iOS lastik bandı): parmağı izler
* ama her piksel bir öncekinden pahalıdır, `olcu`ya yaklaşır ve geçmez.
*/
export function lastik(tasma: number, olcu: number): number {
return (1 - 1 / ((tasma * 0.55) / olcu + 1)) * olcu;
}
/** `lastik`in tersi: ekrandaki esnemeden parmağın gerçek yolunu geri verir. */
export function lastikTersi(esneme: number, olcu: number): number {
return ((1 / (1 - Math.min(esneme, olcu - 1) / olcu) - 1) * olcu) / 0.55;
}
/** Son ~80 ms'lik örneklerden bırakma hızı (px/sn); bırakmadan önce duran parmak 0 verir. */
export function birakmaHizi(ornekler: { t: number; v: number }[], simdi: number): number {
const yakin = ornekler.filter((o) => simdi - o.t <= 80);
const ilk = yakin[0];
const son = yakin[yakin.length - 1];
if (!ilk || !son || ilk === son || simdi - son.t > 60) return 0;
return (son.v - ilk.v) / ((son.t - ilk.t) / 1000);
}

View File

@@ -177,6 +177,13 @@ function dilimBasinaKirp(havuz: AdayProgram[], n: number): AdayProgram[] {
);
}
/** Revizyonun "ekle" kimlikleri: dilim evreninde bulunanlar, katman 0 ile. */
function ekstraSec(evren: AdayProgram[], idler?: readonly string[]): AdayProgram[] {
if (!idler?.length) return [];
const iste = new Set(idler);
return evren.filter((p) => iste.has(p.id));
}
/**
* Katmanları öncelik sırasıyla, tekrarsız birleştirir. Gevşetmede kullanılır:
* gevşeyen filtreye UYAN programlar önce gelir ve kırpmada korunur, kalan
@@ -271,9 +278,24 @@ function dilimEkle(r: {
* Adayın yazamayacağı programlar hiçbir basamakta havuza girmez: başarı
* sırası barajının gerisinde kaldığı bölümler ve özel kontenjan satırları.
*/
/**
* Sohbet revizyonunun havuza dokunan kısmı (lib/ai/rapor.ts kurar):
* `cikar` eşleşenler hiçbir katmana girmez; `oncelikli` eşleşenler kırpmadan
* önce dilim içinde öne alınır ("ilk 5 İstanbul olsun" için İstanbul havuzda
* kalsın); `ekstraIdler` adayın açıkça istediği programlar — sihirbaz
* süzgecine takılsa bile (baraj ve özel kontenjan kuralı yine geçer) havuza
* kendi dilimiyle eklenir.
*/
export type HavuzSecenekleri = {
cikar?: (p: AdayProgram) => boolean;
oncelikli?: (p: AdayProgram) => boolean;
ekstraIdler?: readonly string[];
};
export function havuzOlustur(
params: RaporParams,
profil?: DanismanProfili,
secenekler: HavuzSecenekleri = {},
): HavuzSonuc {
const uniturGrubu =
params.universiteTipi && params.universiteTipi !== "farketmez"
@@ -307,7 +329,39 @@ export function havuzOlustur(
const adaylar = (ozellik?: OzellikSuzgeci) =>
dilimEkle(
havuzAdaylari(params.sira, params.tur, { uniturGrubu, ozellik }),
).filter((p) => !ozelKontenjanSatiriMi(p.isim));
).filter(
(p) => !ozelKontenjanSatiriMi(p.isim) && !(secenekler.cikar?.(p) ?? false),
);
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS). Süzgeçsiz
// dilim evreni (ilsizHam) revizyonun "ekle" kimliklerinin de kaynağıdır.
const ilsizHam = adaylar();
const ekstra = ekstraSec(ilsizHam, secenekler.ekstraIdler);
// Revizyon önceliği gevşetme katmanından sonra, uyum puanından önce gelir:
// kararlı sıralama, eşleşenler dilim içinde öne (kırpma öndekileri tutar).
const oncelikle = (havuz: AdayProgram[]) =>
secenekler.oncelikli
? [...havuz].sort(
(a, b) =>
(a.katman ?? 0) - (b.katman ?? 0) ||
Number(secenekler.oncelikli!(b)) - Number(secenekler.oncelikli!(a)),
)
: havuz;
// Ekstralar katman 0 ile başa; ötekilerin gevşetme katmanı korunur
const ekstraIdKumesi = new Set(ekstra.map((p) => p.id));
const kirpVe = (havuz: AdayProgram[]) =>
dilimBasinaKirp(
oncelikle(
uyumlaSirala(
[
...ekstra.map((p) => ({ ...p, katman: 0 })),
...havuz.filter((p) => !ekstraIdKumesi.has(p.id)),
],
baglam,
),
),
PROMPT_LIMIT_DILIM,
);
const profilGevsetildi: Gevsetilen[] = [];
// Profil gevşerse profile uyan (24'ten az) programlar yine en önde tutulur
@@ -320,22 +374,17 @@ export function havuzOlustur(
profilliFiltreli = kategoriFiltre(profilliHam);
if (profilliFiltreli.length >= HEDEF) {
return {
havuz: dilimBasinaKirp(uyumlaSirala(profilliFiltreli, baglam), PROMPT_LIMIT_DILIM),
havuz: kirpVe(profilliFiltreli),
gevsetildi: [],
};
}
profilGevsetildi.push("profil");
}
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS)
const ilsizHam = adaylar();
const tamHam = ilFiltre(ilsizHam, iller);
const tamFiltreli = kategoriFiltre(tamHam);
const kirp = (...katmanlar: AdayProgram[][]) =>
dilimBasinaKirp(
uyumlaSirala(oncelikliBirlestir(profilliFiltreli, ...katmanlar), baglam),
PROMPT_LIMIT_DILIM,
);
kirpVe(oncelikliBirlestir(profilliFiltreli, ...katmanlar));
if (tamFiltreli.length >= HEDEF) {
return { havuz: kirp(tamFiltreli), gevsetildi: profilGevsetildi };
}

220
src/lib/revizyon-kurali.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Sohbetten gelen liste revizyonu kurallarının (RevizyonKurali) saf
* uygulayıcıları. Eski revizyon yolu serbest metni modele "istek" olarak
* veriyordu; sıra ve havuz koddan belirlendiği için "ilk 5 İstanbul olsun"
* gibi isteklerin karşılığı yoktu (geri bildirim, 7 Eki 2026). Şimdi kural
* yapısaldır ve üç yerde koddan uygulanır:
* 1. Havuz: il / alan / üniversite tipi farkı sihirbaz seçiminin üstüne
* biner (`efektifParams`), "çıkar" havuza hiç girmez, "ilk N" ölçütü
* ve "ekle" kimlikleri kırpmada öne alınır (rapor-havuzu.ts).
* 2. Seçim: iskelet dilim hedefini değiştirir; tamamlama/kırpma ölçüte
* uyanı önceler (lib/ai/rapor.ts).
* 3. Sıra: liste kurulduktan sonra "ilk N" ölçütüne uyanlar başa alınır
* (`ilkTercihleriOneAl`).
*
* Saf modül: DB'ye ve Next'e dokunmaz; sunucu ve betikler birlikte kullanır.
*/
import type { RaporParams, RaporTercih, RevizyonKurali, ListeFarki } from "@/features/rapor/types/rapor";
import type { AdayProgram, DilimHedef } from "./rapor-havuzu";
import { IDEAL_HEDEF, IDEAL_HEDEF_KOLAY_YERLESME, idealHedef } from "./rapor-havuzu";
import { kategoriEslesir } from "./kategoriler";
import { uniturDevletMi } from "./unitur";
import type { UniturGrubu } from "@/types/yokatlas";
/** Hayal ağırlıklı iskelet: 7 hayal / 12 dengeli / 5 güvenli (toplam 24). */
export const IDEAL_HEDEF_HAYAL_AGIRLIKLI: DilimHedef = { hayal: 7, dengeli: 12, garanti: 5 };
const tr = (s: string) => s.toLocaleLowerCase("tr-TR").trim();
function farkUygula(taban: readonly string[], fark: ListeFarki | undefined): string[] {
if (!fark) return [...taban];
let liste = fark.yalniz ? [...fark.yalniz] : [...taban];
if (fark.ekle) liste = [...liste, ...fark.ekle];
if (fark.cikar) {
const cikar = new Set(fark.cikar.map(tr));
liste = liste.filter((x) => !cikar.has(tr(x)));
}
return [...new Set(liste)];
}
/**
* Havuzun kurulacağı etkin parametreler: sihirbaz seçimi + revizyon farkı.
* Kayıtlı `params` değişmez (fark ekranı sihirbaz seçimine bakar); yalnız
* üretim bu kopyayı görür.
*/
export function efektifParams(params: RaporParams): RaporParams {
const k = params.revizyon;
if (!k) return params;
const sonuc: RaporParams = { ...params };
if (k.iller) {
sonuc.iller = farkUygula(params.iller ?? (params.il ? [params.il] : []), k.iller);
if (k.iller.yalniz) delete sonuc.il;
}
if (k.kategoriler) sonuc.kategoriler = farkUygula(params.kategoriler ?? [], k.kategoriler);
if (k.universiteTipi) sonuc.universiteTipi = k.universiteTipi;
// "İlk N" ölçütü sihirbaz süzgecinin dışındaysa havuz o programları hiç
// görmezdi (7 Eki 2026 denemesi: sihirbazda Ankara/İzmir, istek "ilk 5
// İstanbul" → model iller.ekle vermedi, listede İstanbul çıkmadı). Ölçütün
// ili/alanı süzgece eklenir, tip çelişiyorsa süzgeç gevşer; model bunu
// hatırlamak zorunda kalmaz.
const o = k.ilkTercihler;
if (o) {
const iller = sonuc.iller ?? (sonuc.il ? [sonuc.il] : []);
if (o.iller?.length && iller.length > 0) {
const var_ = new Set(iller.map(tr));
const eksik = o.iller.filter((il) => !var_.has(tr(il)));
if (eksik.length) sonuc.iller = [...iller, ...eksik];
}
if (o.kategoriler?.length && (sonuc.kategoriler?.length ?? 0) > 0) {
const var_ = new Set(sonuc.kategoriler!.map(tr));
const eksik = o.kategoriler.filter((kat) => !var_.has(tr(kat)));
if (eksik.length) sonuc.kategoriler = [...sonuc.kategoriler!, ...eksik];
}
if (
o.universiteTipi &&
sonuc.universiteTipi &&
sonuc.universiteTipi !== "farketmez" &&
sonuc.universiteTipi !== o.universiteTipi
) {
sonuc.universiteTipi = "farketmez";
}
}
return sonuc;
}
/** Revizyonun iskelet seçimi; yoksa sihirbaz önceliğine göre varsayılan. */
export function revizyonIskeleti(params: RaporParams): DilimHedef {
switch (params.revizyon?.iskelet) {
case "guvenli-agirlikli":
return IDEAL_HEDEF_KOLAY_YERLESME;
case "hayal-agirlikli":
return IDEAL_HEDEF_HAYAL_AGIRLIKLI;
case "standart":
return IDEAL_HEDEF;
default:
return idealHedef(params.oncelikler);
}
}
type Olcut = NonNullable<RevizyonKurali["ilkTercihler"]>;
/** Program "ilk N" ölçütüne uyuyor mu (verilen her ölçüt birlikte sağlanmalı). */
export function olcuteUyar(
p: Pick<AdayProgram, "isim" | "il" | "unitur" | "fakulte">,
olcut: Pick<Olcut, "iller" | "kategoriler" | "universiteTipi">,
): boolean {
if (olcut.iller?.length) {
const iller = new Set(olcut.iller.map(tr));
if (!p.il || !iller.has(tr(p.il))) return false;
}
if (olcut.kategoriler?.length && !kategoriEslesir(p.isim, olcut.kategoriler, p.fakulte)) {
return false;
}
if (olcut.universiteTipi) {
const devlet = uniturDevletMi(p.unitur);
if ((olcut.universiteTipi === "devlet") !== devlet) return false;
}
return true;
}
function olcutBos(o: Pick<Olcut, "iller" | "kategoriler" | "universiteTipi"> | undefined): boolean {
return !o || (!o.iller?.length && !o.kategoriler?.length && !o.universiteTipi);
}
/** Havuz kırpmasında ve seçim tamamlamasında öncelenecek program süzgeci; ölçüt yoksa null. */
export function oncelikSuzgeci(k: RevizyonKurali | undefined): ((p: AdayProgram) => boolean) | null {
const o = k?.ilkTercihler;
if (!o || olcutBos(o)) return null;
return (p) => olcuteUyar(p, o);
}
/** "Çıkar" kuralı: üniversite / bölüm adı (büyük-küçük harfsiz içerir) ya da kimlik. */
export function cikarSuzgeci(k: RevizyonKurali | undefined): ((p: AdayProgram) => boolean) | null {
const c = k?.cikar;
if (!c || (!c.universiteler?.length && !c.bolumler?.length && !c.programIdler?.length)) return null;
const uniler = (c.universiteler ?? []).map(tr).filter(Boolean);
const bolumler = (c.bolumler ?? []).map(tr).filter(Boolean);
const idler = new Set(c.programIdler ?? []);
return (p) =>
idler.has(p.id) ||
uniler.some((u) => tr(p.universite).includes(u)) ||
bolumler.some((b) => tr(p.isim).includes(b));
}
/**
* Kurulmuş listede "ilk N" ölçütüne uyan ilk N satırı başa alır, kalanların
* sırası korunur; `sira` yeniden numaralanır. Dilim sırası (hayal → dengeli →
* güvenli) bu durumda bilerek bozulur: aday açıkça istedi, danışman da
* cevabında söyler. Ölçüte uyan satır sayısı N'den azsa eldeki kadarı öne alınır.
*/
export function ilkTercihleriOneAl<T extends Pick<RaporTercih, "programId" | "sira">>(
tercihler: T[],
k: RevizyonKurali | undefined,
program: (id: string) => Pick<AdayProgram, "isim" | "il" | "unitur" | "fakulte"> | undefined,
): { tercihler: T[]; oneAlinan: number } {
const o = k?.ilkTercihler;
if (!o || olcutBos(o) || o.adet < 1) return { tercihler, oneAlinan: 0 };
const on: T[] = [];
const kalan: T[] = [];
for (const t of tercihler) {
const p = program(t.programId);
if (on.length < o.adet && p && olcuteUyar(p, o)) on.push(t);
else kalan.push(t);
}
return {
tercihler: [...on, ...kalan].map((t, i) => ({ ...t, sira: i + 1 })),
oneAlinan: on.length,
};
}
/** Yeni revizyon eskisinin üstüne üst anahtar bazında biner; `not` yenilenir. */
export function kurallariBirlestir(
eski: RevizyonKurali | undefined,
yeni: RevizyonKurali,
): RevizyonKurali {
const sonuc: RevizyonKurali = { ...(eski ?? {}) };
delete sonuc.not;
for (const [anahtar, deger] of Object.entries(yeni)) {
if (deger === undefined || deger === null) continue;
(sonuc as Record<string, unknown>)[anahtar] = deger;
}
return sonuc;
}
const TIP_ETIKET: Record<UniturGrubu | "farketmez", string> = {
devlet: "devlet",
vakif: "vakıf",
farketmez: "fark etmez",
};
/** Kuralların modele ve adaya okunur dökümü (prompt + liste üst metni). */
export function kuralOzeti(k: RevizyonKurali | undefined): string[] {
if (!k) return [];
const s: string[] = [];
const fark = (ad: string, f: ListeFarki | undefined) => {
if (!f) return;
if (f.yalniz) s.push(`${ad}: yalnız ${f.yalniz.join(", ")}`);
if (f.ekle?.length) s.push(`${ad}: ${f.ekle.join(", ")} eklendi`);
if (f.cikar?.length) s.push(`${ad}: ${f.cikar.join(", ")} çıkarıldı`);
};
fark("iller", k.iller);
fark("ilgi alanları", k.kategoriler);
if (k.universiteTipi) s.push(`üniversite tipi: ${TIP_ETIKET[k.universiteTipi]}`);
if (k.iskelet && k.iskelet !== "standart") {
s.push(k.iskelet === "guvenli-agirlikli" ? "iskelet: güvenli ağırlıklı (4/12/8)" : "iskelet: hayal ağırlıklı (7/12/5)");
}
const o = k.ilkTercihler;
if (o && !olcutBos(o)) {
const olcut = [
o.iller?.length ? `il ${o.iller.join("/")}` : null,
o.kategoriler?.length ? `alan ${o.kategoriler.join("/")}` : null,
o.universiteTipi ? TIP_ETIKET[o.universiteTipi] : null,
].filter(Boolean);
s.push(`ilk ${o.adet} tercih: ${olcut.join(", ")}`);
}
if (k.cikar?.universiteler?.length) s.push(`çıkarılan üniversiteler: ${k.cikar.universiteler.join(", ")}`);
if (k.cikar?.bolumler?.length) s.push(`çıkarılan bölümler: ${k.cikar.bolumler.join(", ")}`);
if (k.cikar?.programIdler?.length) s.push(`çıkarılan ${k.cikar.programIdler.length} program`);
if (k.ekle?.programIdler?.length) s.push(`eklenen ${k.ekle.programIdler.length} program`);
return s;
}