Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bab50337 |
@@ -23,7 +23,6 @@
|
|||||||
},
|
},
|
||||||
"registries": {
|
"registries": {
|
||||||
"@skiper-ui": "https://skiper-ui.com/registry/{name}.json",
|
"@skiper-ui": "https://skiper-ui.com/registry/{name}.json",
|
||||||
"@magicui": "https://magicui.design/r/{name}",
|
"@magicui": "https://magicui.design/r/{name}"
|
||||||
"@uiarc": "https://uiarc.dev/r/{name}.json"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,6 @@ 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -247,10 +247,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`
|
### 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"`.
|
`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.
|
Özel yerleşim gerekiyorsa `showCloseButton={false}` ver ve **aynı sınıf setini** kopyala.
|
||||||
Alt sayfada (`AltSayfa`) aynı sınıf seti `AltSayfaKapat` üstünde kullanılır (§4.10).
|
Alt sayfada (`Drawer`) aynı sınıf seti `DrawerClose` üstünde kullanılır (§4.10).
|
||||||
|
|
||||||
### 4.4 Kart ve kutu
|
### 4.4 Kart ve kutu
|
||||||
- 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.
|
- 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.)
|
||||||
- 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`.
|
- 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).
|
- İ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.
|
- Gölge: `shadow-sm` varsayılan; hover'da `shadow-lg shadow-orange-500/5`. Başka gölge yok.
|
||||||
@@ -320,19 +320,16 @@ yanındaki 2×2).
|
|||||||
(KKTC uyruklu, açıköğretim ve uzaktan satırları yelpazeye girmez; doluluk bütün satırlar.
|
(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,
|
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).
|
ad kuralı kalır — yoksa DAÜ gibi kurumlarda kartlar "henüz taban oluşmadı" der, 6 Eki 2026 QA).
|
||||||
Doluluk yüzdesi boş kontenjan varken 100'e yuvarlanmaz, en çok 99 yazar.
|
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-3`, telefonda tek kalan üçüncü kart
|
- **Bölüm** (`grid grid-cols-2 gap-4 lg:grid-cols-4`, haritası yok): En seçici (sıra +
|
||||||
`col-span-2`; haritası yok): En yüksek taban (sıra + üniversite adı linkli) · En düşük taban
|
üniversite adı linkli) · En geniş kapı (ücretsiz devlet örgün programı ≥3 ise devlet
|
||||||
(ücretsiz devlet örgün programı ≥3 ise devlet kümesinde, açıklama "Devlet · Bingöl Üni. ·
|
kümesinde, açıklama "Devlet · Bingöl Üni. · 2026 taban sırası"; etiket "Devlette en geniş"
|
||||||
2026 taban sırası"; etiket "Devlette en düşük" 131 px'te iki satıra kırıldığı için kısa
|
131 px'te iki satıra kırıldığı için kısa tutulur) · Orta taban (devlet programlarını sıraya
|
||||||
tutulur) · Boş kontenjan. Orta taban kartı 6 Eki 2026'da kaldırıldı (Bilal: fazla geldi);
|
dizince ortadakinin sırası; açıklamada "Devlet · 2021'de 105 bin · gevşedi") · Boş koltuk.
|
||||||
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.
|
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 yüksek taban (sıra + bölüm adı linkli) · En düşük
|
- **Üniversite** (harita yanındaki 2×2): En seçici bölüm (sıra + bölüm adı linkli) · Orta
|
||||||
taban (aynı biçim) · Doluluk (`%97`) · Türkiye sırası (URAP; listede olmayan kurumda
|
taban (lisans programlarının ortası) · Doluluk (`%97`) · Türkiye sırası (URAP; listede
|
||||||
Kontenjan).
|
olmayan kurumda Kontenjan).
|
||||||
- Sayının yanındaki ad (`KartBaglantisi`, `taban-karti.tsx`) karşı sayfaya gider: bölümde
|
- 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
|
ü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").
|
hover:text-orange-500 hover:underline`). Üniversite adı kısaltılır ("Koç Üni.", "… MYO").
|
||||||
@@ -358,26 +355,10 @@ yanındaki 2×2).
|
|||||||
en üstte. Bugün her sayfada tek satır var (üniversite: "Akademik göstergeler"; bölüm: "Mezun
|
en üstte. Bugün her sayfada tek satır var (üniversite: "Akademik göstergeler"; bölüm: "Mezun
|
||||||
istihdamı").
|
istihdamı").
|
||||||
|
|
||||||
### 4.10 Alt sayfa — `AltSayfa`, `src/components/ui/alt-sayfa.tsx`
|
### 4.10 Alt sayfa (Drawer) içeriği
|
||||||
**Kabuk tek kaynak** (Bilal, 7 Eki 2026: "bottom drawer yerine bottom sheet"): eski vaul çekmecesi
|
Ayrıntı her zaman **alttan açılan sayfada** (`Drawer handleOnly`, tutamaçlı); ekranın ortasında
|
||||||
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:
|
açılan modal değil. Çekmece kabı ve çarpı `cta-sira-form.tsx`'teki çekmeceyle birebir:
|
||||||
`<AltSayfaIcerik>` + §4.3 çarpısı + `<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">`.
|
`DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"`.
|
||||||
|
|
||||||
İçerik kuralları (Apple ayrıntı sayfası):
|
İçerik kuralları (Apple ayrıntı sayfası):
|
||||||
- **Her ekranda ortalı:** başlık `px-10 text-center font-heading text-2xl font-bold`, altında
|
- **Her ekranda ortalı:** başlık `px-10 text-center font-heading text-2xl font-bold`, altında
|
||||||
@@ -398,8 +379,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.
|
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
|
- **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.
|
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: `AltSayfaBaslik` ortalı,
|
"Saniyede gör" çekmecesi (`cta-sira-form.tsx`) bu düzene çevrildi: `DrawerTitle` ortalı,
|
||||||
`AltSayfaAciklama` bağlam, altında form; "neler alacaksın" çizgili ikon listesi kaldırıldı
|
`DrawerDescription` 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ğ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).
|
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
|
- **Arama motoru uyarısı:** çekmece içeriği sayfa açılırken HTML'de yoktur (kapalıyken
|
||||||
@@ -524,30 +505,13 @@ 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).
|
- 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`.
|
- 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 yığını (toast) — `src/components/ui/bildirim.tsx`
|
### 4.16 Bildirim (toast) — `src/components/ui/sonner.tsx` + `globals.css`
|
||||||
Bilal (7 Eki 2026): "toastlarımız için Toast stack kullanalım." `sonner` kaldırıldı; yerinde Arc UI
|
Tema **sitenin temasını izler** (`useTheme().resolvedTheme`, §2.3), sistemin değil: sistem koyu
|
||||||
"Toast stack"ten taşınan `BildirimYigini` (layout'ta bir kez) ve aynı çağrı biçimli `toast` var
|
temadayken sonner açıklamayı beyaza yakın griye çevirip beyaz zeminde okunmaz ediyordu (Bilal,
|
||||||
(`toast.success(başlık, { description, action, id, duration })` — yalnız import yolu değişti).
|
24 Eyl 2026: "toast açıklaması okunmuyor"). Kutu `rounded-2xl border-slate-200`; başlık `font-semibold text-slate-900`; açıklama
|
||||||
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`,
|
`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ı
|
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.
|
(`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`
|
### 4.17 Şehir çipleri — `SehirCipleri`, `src/features/katalog/components/sehir-cipleri.tsx`
|
||||||
Şehre göre üniversite gezinmesi (`/universiteler` üstü ve `/universiteler/[il]` altı).
|
Şehre göre üniversite gezinmesi (`/universiteler` üstü ve `/universiteler/[il]` altı).
|
||||||
@@ -625,7 +589,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".
|
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.
|
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,
|
**Kural:** program satırından açılan her ayrıntı (netler, akademik göstergeler,
|
||||||
kontenjan geçmişi) §4.10 **alt sayfadır** — `AltSayfa`, kap ve çarpı `cta-sira-form.tsx`
|
kontenjan geçmişi) §4.10 **alt sayfadır** — `Drawer handleOnly`, 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
|
ç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
|
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
|
`text-sm` (gövde `text-xs`'e inmez, §3), sayılar `font-sans tabular-nums`, **en yeni yıl** koyu
|
||||||
@@ -682,21 +646,7 @@ 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)
|
`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
|
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.
|
uzar. `Minimize2` geri alır; sohbet durumu (useChat, kaydırma) korunur. Hareketi azalt: geçişsiz.
|
||||||
Perde, sabit konum, ayrı `Dialog`/`AltSayfa` **yok** — sohbet yeniden kurulmaz, sayfa zıplamaz.
|
Perde, sabit konum, ayrı `Dialog`/`Drawer` **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
|
- **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/…`,
|
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**
|
`/universiteler/<il>`, `/paket`, `/listem`) prompt-kit `Source` kalıbında **küçük satır içi hap**
|
||||||
@@ -748,189 +698,6 @@ 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
|
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.
|
`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
|
|
||||||
"Yeniden gönder (59)" sayacı, kaydet bildirimindeki seçim sayısı) rulo gibi döner:
|
|
||||||
eklemede yukarı, çıkarmada aşağı; basamak artınca yeni sütun yayla açılır. Yazı boyu/rengi
|
|
||||||
kapsayıcıdan gelir. Yay §6 varsayılanı; hareketi azalt'ta düz metin; ekran okuyucu düz metni
|
|
||||||
okur. Kaynak Arc UI "Number field" (uiarc.dev, 6 Eki 2026); oradaki −/+ adım butonları ve
|
|
||||||
sürükleme alınmadı — sıralama girişi `Input` hapı olarak kalır (§4.6), rulo yalnız gösterime
|
|
||||||
takılır. Durağan sayıya (taban sıralaması, kontenjan) takılmaz. Geri sayım gibi basamak
|
|
||||||
dolgusu isteyen sayaçta `enAzBasamak={2}` ("05") ve sabit `yon={-1}` (00 → 59 sarması da aşağı
|
|
||||||
döner) verilir; sitede ikinci bir rakam kütüphanesi yoktur (`@number-flow/react` 6 Eki 2026'da
|
|
||||||
kaldırıldı, üst şerit de bu ruloyu kullanır).
|
|
||||||
|
|
||||||
### 4.22 Kayan vurgulu seçim hapları — `SecimHaplari`, `src/components/ui/secim-haplari.tsx`
|
|
||||||
Tek seçimli hap grubunda seçili vurgu yeniden boyanmaz, tek yüzey olarak seçenekten seçeneğe
|
|
||||||
**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 — 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
|
## 5. Yerleşim
|
||||||
|
|
||||||
### 5.1 Genel
|
### 5.1 Genel
|
||||||
@@ -989,15 +756,11 @@ sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerek
|
|||||||
|
|
||||||
## 6. Derinlik, katman ve hareket
|
## 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ç). 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`).
|
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`).
|
||||||
|
|
||||||
Hareket kuralları (Apple, `motion/react`):
|
Hareket kuralları (Apple, `motion/react`):
|
||||||
- Varsayılan yay: `{ type: "spring", bounce: 0, duration: 0.35 }` — sekmesiz, sakin. Sekme
|
- 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).
|
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).
|
- 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ı).
|
- 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.
|
- Hover'da ok `translate-x-0.5` kayar; kart yükselmesi yalnız landing kartında.
|
||||||
@@ -1034,7 +797,7 @@ Hareket kuralları (Apple, `motion/react`):
|
|||||||
tasarlanır. Ortalı ↔ sola yaslı geçişi `lg`'dedir (§5.2).
|
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.
|
- 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.
|
- 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 alt sayfada (`AltSayfa`, §4.10), alttan açılır.
|
- Modal genişliği `max-w-[calc(100%-2rem)]`; ayrıntı her zaman çekmecede (`Drawer`), alttan açılır.
|
||||||
- Çekmece içeriği 375×812'de kaydırmasız sığar (§4.10).
|
- Ç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`).
|
- `prefers-reduced-motion`: her animasyonun sade karşılığı var (`motion-reduce:animate-none`, `.kt-unblur-sade`, `useReducedMotion`).
|
||||||
|
|
||||||
@@ -1095,7 +858,7 @@ Bir ekranı dönüştüren ajan o ekrandaki uyarıları sıfırlar ya da neden k
|
|||||||
### 9.5 Bitirmeden
|
### 9.5 Bitirmeden
|
||||||
`pnpm tasarim:denetim` sıfır hata (uyarılar raporda listelenir) + 375 px'te tek bakış (taşma,
|
`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.
|
üst üste binme, dokunma hedefi, çekmece tek ekran) + §9.2 ölçümleri.
|
||||||
- Yeni ayrıntı paneli mi? `Dialog` değil `AltSayfa` (§4.10, §4.19). İçinde tablo mu? §4.15
|
- Yeni ayrıntı paneli mi? `Dialog` değil `Drawer` (§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).
|
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ı)
|
## 10. Samimi ton, ilerleme ve kutlama (öğrenci ekranları)
|
||||||
@@ -1142,7 +905,7 @@ Araştırma ve kaynaklar: `docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md`
|
|||||||
### 10.6 Anlamsal yapı
|
### 10.6 Anlamsal yapı
|
||||||
- Sayfa başına tek `h1`; bölümler `h2`. Başlıklarda `text-balance`.
|
- 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ı,
|
- Odak almayan durum değişikliği ekran okuyucuya duyurulur: `role="status"` (liste sayacı,
|
||||||
sihirbaz adımı, bekleme). Bildirim yığını (§4.16) bunu kendisi yapar.
|
sihirbaz adımı, bekleme). Bildirimler (sonner) bunu kendisi yapar.
|
||||||
- Açılır satır: `aria-expanded` + `aria-controls` (APG disclosure).
|
- Açılır satır: `aria-expanded` + `aria-controls` (APG disclosure).
|
||||||
- Site geneli "İçeriğe geç" linki `layout.tsx`'te; içerik kabı `#icerik`.
|
- Site geneli "İçeriğe geç" linki `layout.tsx`'te; içerik kabı `#icerik`.
|
||||||
- Çekmece kaydırma alanı `overscroll-contain`.
|
- Çekmece kaydırma alanı `overscroll-contain`.
|
||||||
@@ -1217,7 +980,6 @@ karar.
|
|||||||
Aynı sayfadaki iki band farklı `seed` alır.
|
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`**
|
**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
|
(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:
|
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.
|
mavi `CtaBandi`, koyu kapanış kartı `PikselCtaYuzeyi`, arama penceresindeki koyu kart.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `chat_messages` ADD `meta` text;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -43,13 +43,6 @@
|
|||||||
"when": 1790287503094,
|
"when": 1790287503094,
|
||||||
"tag": "0005_uneven_omega_red",
|
"tag": "0005_uneven_omega_red",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 6,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1791372024282,
|
|
||||||
"tag": "0006_little_luckman",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kolaytercih",
|
"name": "kolaytercih",
|
||||||
"version": "2.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
"@ai-sdk/react": "^4.0.130",
|
"@ai-sdk/react": "^4.0.130",
|
||||||
"@cds/city": "^1.1.0",
|
"@cds/city": "^1.1.0",
|
||||||
"@libsql/client": "^0.17.4",
|
"@libsql/client": "^0.17.4",
|
||||||
|
"@number-flow/react": "^0.6.2",
|
||||||
"@shadcn/helpers": "^0.2.0",
|
"@shadcn/helpers": "^0.2.0",
|
||||||
"@shadcn/react": "0.3.1",
|
|
||||||
"ai": "^7.0.127",
|
"ai": "^7.0.127",
|
||||||
"better-auth": "^1.6.23",
|
"better-auth": "^1.6.23",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
@@ -57,10 +57,12 @@
|
|||||||
"resend": "^6.18.0",
|
"resend": "^6.18.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shiki": "^4.3.1",
|
"shiki": "^4.3.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"turkey-map-react": "^2.0.5",
|
"turkey-map-react": "^2.0.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"use-stick-to-bottom": "^1.1.6",
|
"use-stick-to-bottom": "^1.1.6",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
79
pnpm-lock.yaml
generated
79
pnpm-lock.yaml
generated
@@ -20,12 +20,12 @@ importers:
|
|||||||
'@libsql/client':
|
'@libsql/client':
|
||||||
specifier: ^0.17.4
|
specifier: ^0.17.4
|
||||||
version: 0.17.4
|
version: 0.17.4
|
||||||
|
'@number-flow/react':
|
||||||
|
specifier: ^0.6.2
|
||||||
|
version: 0.6.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@shadcn/helpers':
|
'@shadcn/helpers':
|
||||||
specifier: ^0.2.0
|
specifier: ^0.2.0
|
||||||
version: 0.2.0(ai@7.0.127(zod@4.4.3))
|
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:
|
ai:
|
||||||
specifier: ^7.0.127
|
specifier: ^7.0.127
|
||||||
version: 7.0.127(zod@4.4.3)
|
version: 7.0.127(zod@4.4.3)
|
||||||
@@ -98,6 +98,9 @@ importers:
|
|||||||
shiki:
|
shiki:
|
||||||
specifier: ^4.3.1
|
specifier: ^4.3.1
|
||||||
version: 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:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -110,6 +113,9 @@ importers:
|
|||||||
use-stick-to-bottom:
|
use-stick-to-bottom:
|
||||||
specifier: ^1.1.6
|
specifier: ^1.1.6
|
||||||
version: 1.1.6(react@19.2.4)
|
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:
|
zod:
|
||||||
specifier: ^4.4.3
|
specifier: ^4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -1283,6 +1289,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@number-flow/react@0.6.2':
|
||||||
|
resolution: {integrity: sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
react-dom: ^18 || ^19
|
||||||
|
|
||||||
'@opentelemetry/semantic-conventions@1.43.0':
|
'@opentelemetry/semantic-conventions@1.43.0':
|
||||||
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
|
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2008,17 +2020,6 @@ packages:
|
|||||||
ai:
|
ai:
|
||||||
optional: true
|
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':
|
'@shikijs/core@4.3.1':
|
||||||
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
|
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3546,6 +3547,9 @@ packages:
|
|||||||
jiti:
|
jiti:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
esm-env@1.2.2:
|
||||||
|
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
||||||
|
|
||||||
espree@10.4.0:
|
espree@10.4.0:
|
||||||
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -4688,6 +4692,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
|
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
number-flow@0.6.2:
|
||||||
|
resolution: {integrity: sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==}
|
||||||
|
|
||||||
oauth-sign@0.9.0:
|
oauth-sign@0.9.0:
|
||||||
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
|
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
|
||||||
|
|
||||||
@@ -5271,6 +5278,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
||||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
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:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -5656,6 +5669,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
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:
|
verror@1.10.0:
|
||||||
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
|
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
|
||||||
engines: {'0': node >=0.6.0}
|
engines: {'0': node >=0.6.0}
|
||||||
@@ -6674,6 +6693,13 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@number-flow/react@0.6.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
esm-env: 1.2.2
|
||||||
|
number-flow: 0.6.2
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
'@opentelemetry/semantic-conventions@1.43.0': {}
|
'@opentelemetry/semantic-conventions@1.43.0': {}
|
||||||
|
|
||||||
'@postman/form-data@3.1.1':
|
'@postman/form-data@3.1.1':
|
||||||
@@ -7448,11 +7474,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ai: 7.0.127(zod@4.4.3)
|
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':
|
'@shikijs/core@4.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/primitive': 4.3.1
|
'@shikijs/primitive': 4.3.1
|
||||||
@@ -9088,6 +9109,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
esm-env@1.2.2: {}
|
||||||
|
|
||||||
espree@10.4.0:
|
espree@10.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.17.0
|
acorn: 8.17.0
|
||||||
@@ -10412,6 +10435,10 @@ snapshots:
|
|||||||
path-key: 4.0.0
|
path-key: 4.0.0
|
||||||
unicorn-magic: 0.3.0
|
unicorn-magic: 0.3.0
|
||||||
|
|
||||||
|
number-flow@0.6.2:
|
||||||
|
dependencies:
|
||||||
|
esm-env: 1.2.2
|
||||||
|
|
||||||
oauth-sign@0.9.0: {}
|
oauth-sign@0.9.0: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
@@ -11231,6 +11258,11 @@ snapshots:
|
|||||||
ip-address: 10.2.0
|
ip-address: 10.2.0
|
||||||
smart-buffer: 4.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-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -11667,6 +11699,15 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
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:
|
verror@1.10.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
assert-plus: 1.0.0
|
assert-plus: 1.0.0
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const KURALLAR: Kural[] = [
|
|||||||
ad: "modal-rozet",
|
ad: "modal-rozet",
|
||||||
seviye: "hata",
|
seviye: "hata",
|
||||||
mesaj: "Çekmece/modal içinde SectionEyebrow yok; başlık + tek cümle bağlam (DESIGN.md §4.10)",
|
mesaj: "Çekmece/modal içinde SectionEyebrow yok; başlık + tek cümle bağlam (DESIGN.md §4.10)",
|
||||||
bul: blokIcinde(["AltSayfaIcerik", "DialogContent"], /<SectionEyebrow\b/),
|
bul: blokIcinde(["DrawerContent", "DialogContent"], /<SectionEyebrow\b/),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ad: "cta-yildiz",
|
ad: "cta-yildiz",
|
||||||
|
|||||||
@@ -15,14 +15,6 @@ import { barajTablosu, type AracBaglami } from "@/lib/ai/araclar";
|
|||||||
import { danismanAkisi } from "@/lib/ai/sohbet-akisi";
|
import { danismanAkisi } from "@/lib/ai/sohbet-akisi";
|
||||||
import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt";
|
import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt";
|
||||||
import { modelPenceresi, onayMesajiMi } from "@/lib/ai/sohbet-gecmisi";
|
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 { profilGetir } from "@/lib/danisman-profil-db";
|
||||||
import { listeOzetiCikar } from "@/lib/ai/rapor";
|
import { listeOzetiCikar } from "@/lib/ai/rapor";
|
||||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||||
@@ -83,20 +75,11 @@ export async function POST(request: NextRequest) {
|
|||||||
if (hamBody === null || typeof hamBody !== "object") {
|
if (hamBody === null || typeof hamBody !== "object") {
|
||||||
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
|
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
|
||||||
}
|
}
|
||||||
const body = hamBody as {
|
const body = hamBody as { message?: unknown; clientMessageId?: unknown };
|
||||||
message?: unknown;
|
const mesaj = typeof body.message === "string" ? body.message.trim() : "";
|
||||||
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 =
|
const clientMessageId =
|
||||||
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
|
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
|
||||||
const anketCevabiHam =
|
if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
|
||||||
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 });
|
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +101,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}),
|
}),
|
||||||
profilGetir(session.user.id),
|
profilGetir(session.user.id),
|
||||||
]);
|
]);
|
||||||
const paketli = Boolean(kullanici?.hasPaket);
|
|
||||||
if (!rapor?.result) {
|
if (!rapor?.result) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
|
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
|
||||||
@@ -132,27 +114,11 @@ export async function POST(request: NextRequest) {
|
|||||||
// gönderilen "evet"ler bedelsiz model çağrısı açmasın.
|
// gönderilen "evet"ler bedelsiz model çağrısı açmasın.
|
||||||
const sonMesaj = gecmis.at(-1);
|
const sonMesaj = gecmis.at(-1);
|
||||||
const sonAsistan = sonMesaj?.role === "assistant" ? sonMesaj.content : undefined;
|
const sonAsistan = sonMesaj?.role === "assistant" ? sonMesaj.content : undefined;
|
||||||
// Soru formunun cevabı da bedelsiz (7 Eki 2026): yalnız EN SON danışman
|
const bedelsiz = onayMesajiMi(mesaj, sonAsistan);
|
||||||
// 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
|
// Kredi düşümü stream'den önce — yetersizse hiç başlama
|
||||||
const harcama = bedelsiz
|
const harcama = bedelsiz
|
||||||
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId, meta: kullaniciMeta })
|
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
|
||||||
: await spendCreditForMessage({
|
: await spendCreditForMessage({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
content: mesaj,
|
content: mesaj,
|
||||||
@@ -175,7 +141,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
|
// 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.
|
// halde danışman kilitli listeyi sözle sızdırabilir.
|
||||||
const kilitli = !paketli;
|
const kilitli = !kullanici?.hasPaket;
|
||||||
const raporSonuc = rapor.result as RaporSonuc;
|
const raporSonuc = rapor.result as RaporSonuc;
|
||||||
const tamOzet = listeOzetiCikar(
|
const tamOzet = listeOzetiCikar(
|
||||||
raporSonuc,
|
raporSonuc,
|
||||||
@@ -196,7 +162,6 @@ export async function POST(request: NextRequest) {
|
|||||||
uyarilar: raporSonuc.uyarilar,
|
uyarilar: raporSonuc.uyarilar,
|
||||||
kapsam: raporSonuc.kapsam ? { toplam: raporSonuc.kapsam.toplam, uyarlandi: raporSonuc.kapsam.uyarlandi } : undefined,
|
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
|
// 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
|
// (profil_guncelle yazınca aynı tur içinde tazelenir) ve sihirbaz seçimleri
|
||||||
@@ -205,9 +170,6 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
sira: raporParams.sira,
|
sira: raporParams.sira,
|
||||||
tur: raporParams.tur,
|
tur: raporParams.tur,
|
||||||
hasPaket: paketli,
|
|
||||||
mesajId: kullaniciMesajId,
|
|
||||||
kalanRevizyon: Math.max(0, MAX_REVIZYON - rapor.revisionCount),
|
|
||||||
profil,
|
profil,
|
||||||
oncelikler: raporParams.oncelikler ?? [],
|
oncelikler: raporParams.oncelikler ?? [],
|
||||||
ozelDurum: raporParams.ozelDurum ?? [],
|
ozelDurum: raporParams.ozelDurum ?? [],
|
||||||
@@ -267,27 +229,16 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere
|
// Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere
|
||||||
// modele bozuk sıra göndermesin. Kayıtlı asistan metni soru formunu
|
// modele bozuk sıra göndermesin
|
||||||
// içermez; model cevapların neye verildiğini formun dökümünden görür.
|
const mesajlar = modelPenceresi(gecmis, mesaj);
|
||||||
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;
|
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) =>
|
const cevabiKaydet = (metin: string) =>
|
||||||
appDb.insert(schema.chatMessages).values({
|
appDb.insert(schema.chatMessages).values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: metin,
|
content: metin,
|
||||||
meta: Object.keys(cevapMeta).length ? cevapMeta : null,
|
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,15 +248,6 @@ export async function POST(request: NextRequest) {
|
|||||||
system,
|
system,
|
||||||
mesajlar,
|
mesajlar,
|
||||||
aracBaglami,
|
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) => {
|
onTamam: async (metin) => {
|
||||||
await cevabiKaydet(metin);
|
await cevabiKaydet(metin);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -449,38 +449,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bildirim (toast, sonner) — DESIGN.md §4.16. Başlık ve açıklama slate
|
||||||
/* Veri akışı (veri-akisi.tsx, DESIGN.md §4.28): kare, çizgi boyunca karodan
|
tonlarında okunur; aksiyon düğmesi üst çubuktaki koyu hap dilinde.
|
||||||
merkeze akar. Uç noktalar satır içi --x1/--y1/--x2/--y2 ile gelir (SVG
|
".toaster" öneki şart: sonner kendi stilini çalışma anında enjekte eder,
|
||||||
içinde px = viewBox birimi). Yalnız ekrandayken oynar. */
|
aynı özgüllükteki kural onun altında kalır. */
|
||||||
@keyframes kt-akis {
|
.toaster [data-sonner-toast][data-styled="true"] {
|
||||||
0% {
|
border-radius: 1rem;
|
||||||
translate: var(--x1) var(--y1);
|
border-color: var(--color-slate-200);
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
12%,
|
|
||||||
82% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
translate: var(--x2) var(--y2);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.toaster [data-sonner-toast][data-styled="true"] [data-title] {
|
||||||
.kt-akis-kare {
|
font-weight: 600;
|
||||||
translate: var(--x1) var(--y1);
|
color: var(--color-slate-900);
|
||||||
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] {
|
||||||
[data-oynuyor] .kt-akis-kare {
|
color: var(--color-slate-600);
|
||||||
animation-play-state: running;
|
|
||||||
}
|
}
|
||||||
|
.toaster [data-sonner-toast][data-styled="true"] [data-button] {
|
||||||
@media (prefers-reduced-motion: reduce) {
|
height: 2.25rem;
|
||||||
.kt-akis-kare {
|
padding-inline: 0.875rem;
|
||||||
animation: none;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
|
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { BildirimYigini } from "@/components/ui/bildirim";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { SiteTopBanner } from "@/components/site-top-banner";
|
import { SiteTopBanner } from "@/components/site-top-banner";
|
||||||
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
|
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
|
||||||
@@ -129,7 +129,7 @@ export default function RootLayout({
|
|||||||
<TercihProfiliKapisiLazy />
|
<TercihProfiliKapisiLazy />
|
||||||
{/* İlk ziyarette bir kez: çerez bilgilendirmesi (onay penceresi değil) */}
|
{/* İlk ziyarette bir kez: çerez bilgilendirmesi (onay penceresi değil) */}
|
||||||
<CerezBilgi />
|
<CerezBilgi />
|
||||||
<BildirimYigini />
|
<Toaster position="top-center" />
|
||||||
{/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */}
|
{/* DevTools'u açan meraklıya piksel kum saati + Asr Sûresi */}
|
||||||
<ConsoleAsr />
|
<ConsoleAsr />
|
||||||
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
|
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
ListemIcerikSkeleton,
|
ListemIcerikSkeleton,
|
||||||
} from "@/features/rapor/components/listem-icerik";
|
} from "@/features/rapor/components/listem-icerik";
|
||||||
|
|
||||||
// Liste üretimi (listeOlustur) 2 AI denemesi yapabiliyor (deneme başına 60 sn
|
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||||
// abort sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
// 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
|
// 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
|
// bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin
|
||||||
// istek süresi limiti de >= 200 sn olmalı.
|
// istek süresi limiti de >= 200 sn olmalı.
|
||||||
|
|||||||
263
src/app/page.tsx
263
src/app/page.tsx
@@ -1,12 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
BarChart3,
|
||||||
Coins,
|
Coins,
|
||||||
|
Compass,
|
||||||
FileText,
|
FileText,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
TrendingUp,
|
||||||
|
Wallet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
@@ -18,13 +22,9 @@ import { Parallax } from "@/components/parallax";
|
|||||||
import { Reveal } from "@/features/pazarlama/components/reveal";
|
import { Reveal } from "@/features/pazarlama/components/reveal";
|
||||||
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
|
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
|
||||||
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
|
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 {
|
import {
|
||||||
|
PixelCorner,
|
||||||
PixelDivider,
|
PixelDivider,
|
||||||
PixelRakam,
|
|
||||||
PixelSahne,
|
|
||||||
SectionEyebrow,
|
SectionEyebrow,
|
||||||
} from "@/components/pixel-decor";
|
} from "@/components/pixel-decor";
|
||||||
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
|
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
|
||||||
@@ -41,6 +41,12 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -52,9 +58,11 @@ import {
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import {
|
import {
|
||||||
JsonLd,
|
JsonLd,
|
||||||
|
KATALOG_VERI_TARIHI,
|
||||||
faqPageJsonLd,
|
faqPageJsonLd,
|
||||||
softwareApplicationJsonLd,
|
softwareApplicationJsonLd,
|
||||||
} from "@/lib/seo";
|
} from "@/lib/seo";
|
||||||
|
import { KATALOG_TARIH_METNI } from "@/features/katalog/components/veri-kaynagi";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -68,55 +76,20 @@ const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
|
|||||||
maximumFractionDigits: 0,
|
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 = [
|
const problems = [
|
||||||
{
|
{
|
||||||
sahne: SAHNE_IHTIMAL,
|
icon: ListChecks,
|
||||||
title: "24 tercih, binlerce 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.`,
|
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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sahne: SAHNE_YILLAR,
|
icon: TrendingUp,
|
||||||
title: "Geçen yılın puanı yetmiyor",
|
title: "Geçen yılın puanı yetmiyor",
|
||||||
description:
|
description:
|
||||||
"Taban sıralamaları her yıl kayıyor; tek yıla bakmak riski gizler.",
|
"Taban sıralamaları her yıl kayıyor; tek yıla bakmak riski gizler.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sahne: SAHNE_FIYAT,
|
icon: Wallet,
|
||||||
title: "Danışman lüks haline geldi",
|
title: "Danışman lüks haline geldi",
|
||||||
description:
|
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.",
|
"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.",
|
||||||
@@ -125,34 +98,26 @@ const problems = [
|
|||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
step: 1,
|
icon: Compass,
|
||||||
|
step: "1",
|
||||||
title: "Sıralamanı ve hedeflerini anlat",
|
title: "Sıralamanı ve hedeflerini anlat",
|
||||||
description:
|
description:
|
||||||
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.",
|
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: 2,
|
icon: BarChart3,
|
||||||
|
step: "2",
|
||||||
title: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
|
title: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
|
||||||
description:
|
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.",
|
"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.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: 3,
|
icon: ShieldCheck,
|
||||||
|
step: "3",
|
||||||
title: "Dengeli 24'lük listeni al",
|
title: "Dengeli 24'lük listeni al",
|
||||||
description:
|
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.",
|
"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 = [
|
const faqs = [
|
||||||
@@ -252,10 +217,6 @@ const paketOzellikleri = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// Katalog hapları gerçek veriden (landing-verisi.ts); veri okunamazsa
|
|
||||||
// bölüm çizilmez.
|
|
||||||
const haplar = katalogHaplari();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="top"
|
id="top"
|
||||||
@@ -296,6 +257,27 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
<HeroForm />
|
<HeroForm />
|
||||||
</div>
|
</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'ı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>
|
</Parallax>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -321,24 +303,23 @@ export default function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</Parallax>
|
</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">
|
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
|
||||||
{problems.map((p, i) => (
|
{problems.map((p, i) => (
|
||||||
<Reveal key={p.title} delay={i * 0.08} className="h-full">
|
<Reveal key={p.title} delay={i * 0.08}>
|
||||||
<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">
|
<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">
|
||||||
<div className="border-b border-slate-100 bg-slate-50 px-6 py-8">
|
<PixelCorner className="absolute right-4 top-4" />
|
||||||
<PixelSahne izgara={p.sahne} />
|
<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>
|
</div>
|
||||||
<div className="p-6">
|
<CardTitle className="font-heading text-xl">
|
||||||
<h3 className="font-heading text-xl font-bold text-balance">
|
|
||||||
{p.title}
|
{p.title}
|
||||||
</h3>
|
</CardTitle>
|
||||||
<p className="mt-2 text-base leading-relaxed text-slate-600">
|
<CardDescription className="text-base leading-relaxed">
|
||||||
{p.description}
|
{p.description}
|
||||||
</p>
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
</div>
|
</Card>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</Parallax>
|
</Parallax>
|
||||||
@@ -380,83 +361,78 @@ export default function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</Parallax>
|
</Parallax>
|
||||||
{/* Adımlar kartsız (DESIGN.md §4.32): numara hero'nun piksel
|
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
|
||||||
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) => (
|
{steps.map((s, i) => (
|
||||||
<li key={s.step}>
|
<Reveal key={s.step} delay={i * 0.08} className="h-full">
|
||||||
<Reveal
|
<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">
|
||||||
delay={i * 0.08}
|
<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">
|
||||||
className="relative flex flex-col items-center text-center"
|
{s.step}
|
||||||
>
|
</span>
|
||||||
<PixelRakam rakam={s.step} className="h-24 w-auto" />
|
<PixelCorner className="absolute right-4 top-4" />
|
||||||
{i < steps.length - 1 ? (
|
<s.icon className="size-6 text-primary" aria-hidden />
|
||||||
<span
|
<h3 className="mt-4 font-heading text-xl font-semibold">
|
||||||
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}
|
{s.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 max-w-sm text-base leading-relaxed text-slate-600">
|
<p className="mt-2 leading-relaxed text-slate-600">
|
||||||
{s.description}
|
{s.description}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ol>
|
|
||||||
</Parallax>
|
</Parallax>
|
||||||
|
|
||||||
{/* Veri akışı + veriye dayalı güven + dürüstlük notu (DESIGN.md
|
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
|
||||||
§4.28). Üçü de kartsız, sayfa zemininde açıkta durur: geniş
|
<Parallax strength={10} className="mx-auto mt-12 w-full max-w-3xl">
|
||||||
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
|
<Reveal
|
||||||
delay={0.1}
|
delay={0.1}
|
||||||
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"
|
className="overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm"
|
||||||
>
|
>
|
||||||
<VeriAkisi className="lg:order-2" />
|
<dl className="grid grid-cols-1 divide-y divide-slate-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
|
||||||
|
{[
|
||||||
<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) => (
|
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) => (
|
||||||
// Sayı kahramandır (DESIGN.md §1.1): iri sayı üstte, etiket
|
// 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.
|
// altında; dt anlam için DOM'da önce, görsel sıra order ile.
|
||||||
<div key={stat.label} className="flex flex-col">
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className="flex flex-col p-6 text-center"
|
||||||
|
>
|
||||||
<dt className="order-2 mt-2 text-sm font-semibold text-slate-500">
|
<dt className="order-2 mt-2 text-sm font-semibold text-slate-500">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary sm:text-5xl">
|
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary">
|
||||||
{stat.value}
|
{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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<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">
|
<div className="flex gap-4 border-t border-slate-200 bg-primary/5 p-6 text-left sm:items-center sm:p-7">
|
||||||
<SectionEyebrow>Dürüstlük notu</SectionEyebrow>
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white shadow-sm">
|
||||||
<h3 className="mt-4 font-heading text-2xl font-bold text-balance text-slate-900">
|
<ShieldCheck className="size-5" aria-hidden />
|
||||||
Yerleşme garantisi vermiyoruz —{" "}
|
</div>
|
||||||
<span className="font-bricolage font-bold tracking-tighter text-primary italic">
|
<div>
|
||||||
çünkü kimse veremez.
|
<p className="text-sm font-semibold text-primary">
|
||||||
</span>
|
Dürüstlük notu
|
||||||
</h3>
|
</p>
|
||||||
<p className="mt-3 text-base leading-relaxed text-slate-600">
|
<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
|
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
|
yerine riski satır satır gösterir, daha güvenli bir liste
|
||||||
kurmana yardımcı oluruz.
|
kurmana yardımcı oluruz.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</Parallax>
|
</Parallax>
|
||||||
</div>
|
</div>
|
||||||
@@ -692,47 +668,6 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Fiyat → SSS geçişi */}
|
||||||
<div aria-hidden className="mx-auto w-56">
|
<div aria-hidden className="mx-auto w-56">
|
||||||
<Parallax strength={26}>
|
<Parallax strength={26}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Wrench, X } from "lucide-react";
|
import { Wrench, X } from "lucide-react";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
|
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -470,111 +470,6 @@ 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. */
|
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
|
||||||
export function SectionEyebrow({
|
export function SectionEyebrow({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import { startTransition, useEffect, useState } from "react";
|
import { startTransition, useEffect, useState } from "react";
|
||||||
|
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
|
||||||
import { CalendarDays, Clock3 } from "lucide-react";
|
import { CalendarDays, Clock3 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
BANT_ARIA,
|
BANT_ARIA,
|
||||||
@@ -131,18 +131,34 @@ export function SiteTopBanner() {
|
|||||||
: "Geri sayım yükleniyor"
|
: "Geri sayım yükleniyor"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Rulo rakam (DESIGN.md §4.21): her birim kendi tekerleğinde
|
<NumberFlowGroup>
|
||||||
aşağı döner; 00 → 59 sarması da aşağı (yon=-1). */}
|
|
||||||
{BIRIMLER.map(({ anahtar, etiket }) => (
|
{BIRIMLER.map(({ anahtar, etiket }) => (
|
||||||
<div className="flex items-baseline gap-0.5" key={anahtar}>
|
<div className="flex items-baseline gap-0.5" key={anahtar}>
|
||||||
<dt className="sr-only">{etiket}</dt>
|
<dt className="sr-only">{etiket}</dt>
|
||||||
<dd className="font-semibold">
|
<dd className="font-semibold">
|
||||||
{geriSayim ? (
|
{geriSayim ? (
|
||||||
<SayiRulo
|
<NumberFlow
|
||||||
deger={geriSayim[anahtar]}
|
value={geriSayim[anahtar]}
|
||||||
enAzBasamak={2}
|
trend={-1}
|
||||||
yon={-1}
|
format={{
|
||||||
className="min-w-[2ch] justify-end"
|
minimumIntegerDigits: 2,
|
||||||
|
useGrouping: false,
|
||||||
|
}}
|
||||||
|
transformTiming={{
|
||||||
|
duration: 280,
|
||||||
|
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||||
|
}}
|
||||||
|
spinTiming={{
|
||||||
|
duration: 280,
|
||||||
|
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||||
|
}}
|
||||||
|
opacityTiming={{
|
||||||
|
duration: 180,
|
||||||
|
easing: "ease-out",
|
||||||
|
}}
|
||||||
|
willChange={anahtar === "saniye"}
|
||||||
|
className="inline-block min-w-[2ch] text-right"
|
||||||
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
"--"
|
"--"
|
||||||
@@ -153,6 +169,7 @@ export function SiteTopBanner() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</NumberFlowGroup>
|
||||||
</dl>
|
</dl>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,22 +3,20 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { Monitor, Moon, Sun } from "lucide-react";
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
import { SecimHaplari } from "@/components/ui/secim-haplari";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const SECENEKLER = [
|
const SECENEKLER = [
|
||||||
{ deger: "light", etiket: "Açık", ikon: Sun },
|
{ deger: "light", etiket: "Açık", ikon: Sun },
|
||||||
{ deger: "dark", etiket: "Koyu", ikon: Moon },
|
{ deger: "dark", etiket: "Koyu", ikon: Moon },
|
||||||
{ deger: "system", etiket: "Sistem", ikon: Monitor },
|
{ deger: "system", etiket: "Sistem", ikon: Monitor },
|
||||||
] as const;
|
] as const;
|
||||||
type Tema = (typeof SECENEKLER)[number]["deger"];
|
|
||||||
|
|
||||||
const aboneOlma = () => () => {};
|
const aboneOlma = () => () => {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca
|
* Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca
|
||||||
* tarayıcıda bilinir; sunucu HTML'inde hiçbir seçenek işaretli gelmez,
|
* tarayıcıda bilinir; sunucu HTML'inde hiçbir seçenek işaretli gelmez,
|
||||||
* hydration sonrası işaretlenir (yerleşim oynamaz). Koyu vurgu seçenekten
|
* hydration sonrası işaretlenir (yerleşim oynamaz).
|
||||||
* seçeneğe kayar (secim-haplari.tsx, kapsül varyantı).
|
|
||||||
*/
|
*/
|
||||||
export function TemaSecici({ className }: { className?: string }) {
|
export function TemaSecici({ className }: { className?: string }) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -27,19 +25,37 @@ export function TemaSecici({ className }: { className?: string }) {
|
|||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
const secili = tarayicida
|
|
||||||
? (SECENEKLER.find((s) => s.deger === theme)?.deger ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecimHaplari<Tema>
|
<div
|
||||||
ariaLabel="Tema"
|
role="radiogroup"
|
||||||
secenekler={SECENEKLER}
|
aria-label="Tema"
|
||||||
deger={secili}
|
className={cn(
|
||||||
onChange={setTheme}
|
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1",
|
||||||
varyant="kapsul"
|
className,
|
||||||
boyut="md"
|
)}
|
||||||
className={className}
|
>
|
||||||
/>
|
{SECENEKLER.map(({ deger, etiket, ikon: Ikon }) => {
|
||||||
|
const secili = tarayicida && theme === deger;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={deger}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={secili}
|
||||||
|
onClick={() => setTheme(deger)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-sm font-semibold transition-[background-color,color,transform] duration-150 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
|
||||||
|
secili
|
||||||
|
? "bg-slate-900 text-slate-50"
|
||||||
|
: "text-slate-600 hover:text-orange-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Ikon className="size-4" aria-hidden />
|
||||||
|
{etiket}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,686 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
@@ -1,696 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
154
src/components/ui/drawer.tsx
Normal file
154
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"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,
|
||||||
|
}
|
||||||
@@ -1,615 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Rulo rakam (odometer): sayı değişince her basamak kendi tekerleğinde döner —
|
|
||||||
// artarken yukarı, azalırken aşağı; 9 → 0 sarar, 10 → 9 geri döner. Basamak
|
|
||||||
// sayısı değişince (9 → 10) yeni sütun genişliğini yayla açar, kalkan sütun
|
|
||||||
// kapanır; sütunlar basamak değerine göre anahtarlı olduğundan birler hanesi
|
|
||||||
// hep birler hanesi kalır.
|
|
||||||
//
|
|
||||||
// Kaynak: Arc UI "Number field" (uiarc.dev) rakam rulosu. Oradaki adım
|
|
||||||
// butonları, sürükleyerek değiştirme ve sınır notu alınmadı — 6 haneli
|
|
||||||
// sıralama girişine uymuyor; rulo yalnız EKRANDA DEĞİŞEN sayıya takılır
|
|
||||||
// (liste sayacı gibi). Yay DESIGN.md §6 varsayılanı (sekmesiz, 0,35 s);
|
|
||||||
// hareketi azalt'ta düz metin. Ekran okuyucu düz metni okur, rulo aria-hidden.
|
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
AnimatePresence,
|
|
||||||
animate,
|
|
||||||
motion,
|
|
||||||
useMotionValue,
|
|
||||||
useReducedMotion,
|
|
||||||
useTransform,
|
|
||||||
type MotionValue,
|
|
||||||
type Variants,
|
|
||||||
} from "motion/react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
|
|
||||||
const RAKAMLAR = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
const BICIMLER = new Map<number, Intl.NumberFormat>();
|
|
||||||
/** Basamak dolgusu isteyen sayaç (geri sayım "05") binlik ayracı kullanmaz. */
|
|
||||||
function bicimAl(enAzBasamak: number): Intl.NumberFormat {
|
|
||||||
let b = BICIMLER.get(enAzBasamak);
|
|
||||||
if (!b) {
|
|
||||||
b = new Intl.NumberFormat("tr-TR", {
|
|
||||||
numberingSystem: "latn",
|
|
||||||
minimumIntegerDigits: enAzBasamak,
|
|
||||||
useGrouping: enAzBasamak <= 1,
|
|
||||||
});
|
|
||||||
BICIMLER.set(enAzBasamak, b);
|
|
||||||
}
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Parca =
|
|
||||||
| { anahtar: string; rakam: number }
|
|
||||||
| { anahtar: string; metin: string };
|
|
||||||
|
|
||||||
/** Biçimlenmiş sayıyı basamak değerine göre anahtarlı sütunlara böler. */
|
|
||||||
function parcala(deger: number, bicim: Intl.NumberFormat): Parca[] {
|
|
||||||
const parcalar = bicim.formatToParts(deger);
|
|
||||||
let basamak = parcalar.reduce(
|
|
||||||
(n, p) => n + (p.type === "integer" ? p.value.length : 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return parcalar.flatMap((p, i): Parca[] => {
|
|
||||||
if (p.type === "integer")
|
|
||||||
return [...p.value].map((c) => ({ anahtar: `b${--basamak}`, rakam: Number(c) }));
|
|
||||||
return [
|
|
||||||
{ anahtar: p.type === "group" ? `g${basamak}` : `${p.type}${i}`, metin: p.value },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sütun açılırken değişim yönünde yükselir, kapanırken aynı yoldan gider (§6:
|
|
||||||
// giriş ve çıkış aynı yoldan).
|
|
||||||
const yuva: Variants = {
|
|
||||||
giris: (yon: number) => ({ width: 0, opacity: 0, y: `${yon * 0.3}em` }),
|
|
||||||
merkez: { width: "auto", opacity: 1, y: 0, transition: YAY },
|
|
||||||
cikis: (yon: number) => ({
|
|
||||||
width: 0,
|
|
||||||
opacity: 0,
|
|
||||||
y: `${yon * -0.3}em`,
|
|
||||||
transition: YAY,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Tekerlekteki bir rakam: tekerleğin konumuna uzaklığı yerini ve görünürlüğünü belirler. */
|
|
||||||
function Glif({ konum, rakam }: { konum: MotionValue<number>; rakam: number }) {
|
|
||||||
const uzaklik = (simdiki: number) =>
|
|
||||||
((((rakam - simdiki) % 10) + 15) % 10) - 5;
|
|
||||||
const y = useTransform(konum, (c) => `${uzaklik(c) * 1.05}em`);
|
|
||||||
const opacity = useTransform(konum, (c) =>
|
|
||||||
Math.max(0, 1 - Math.abs(uzaklik(c)) ** 1.5 * 1.1),
|
|
||||||
);
|
|
||||||
const visibility = useTransform(konum, (c) =>
|
|
||||||
Math.abs(uzaklik(c)) >= 1 ? "hidden" : "visible",
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
|
||||||
style={{ y, opacity, visibility }}
|
|
||||||
>
|
|
||||||
{rakam}
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bir basamağın tekerleği: sayının gittiği yönde döner, yarım turdan uzunsa kısa yolu seçer. */
|
|
||||||
function Tekerlek({ rakam, yon }: { rakam: number; yon: number }) {
|
|
||||||
const konum = useMotionValue(rakam);
|
|
||||||
const durum = useRef({ rakam, hedef: rakam });
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const d = durum.current;
|
|
||||||
if (d.rakam === rakam) return;
|
|
||||||
let fark =
|
|
||||||
yon > 0 ? (rakam - d.rakam + 10) % 10 : -((d.rakam - rakam + 10) % 10);
|
|
||||||
if (Math.abs(fark) > 5) fark -= Math.sign(fark) * 10;
|
|
||||||
d.hedef += fark;
|
|
||||||
d.rakam = rakam;
|
|
||||||
animate(konum, d.hedef, YAY);
|
|
||||||
}, [rakam, yon, konum]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="invisible">0</span>
|
|
||||||
{RAKAMLAR.map((r) => (
|
|
||||||
<Glif key={r} konum={konum} rakam={r} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ekranda değişen bir sayı (liste sayacı gibi). Yazı boyu/rengi/ağırlığı
|
|
||||||
* kapsayıcıdan gelir; `className` yalnız yerleşim için.
|
|
||||||
*/
|
|
||||||
export function SayiRulo({
|
|
||||||
deger,
|
|
||||||
className,
|
|
||||||
enAzBasamak = 1,
|
|
||||||
yon: sabitYon,
|
|
||||||
}: {
|
|
||||||
deger: number;
|
|
||||||
className?: string;
|
|
||||||
/** Basamak dolgusu: 2 → "05". Üst çubuk geri sayımı; binlik ayracı kapanır. */
|
|
||||||
enAzBasamak?: number;
|
|
||||||
/** Dönüş yönünü sabitler: geri sayımda 00 → 59 yukarı değil aşağı dönsün. */
|
|
||||||
yon?: 1 | -1;
|
|
||||||
}) {
|
|
||||||
const azalt = useReducedMotion();
|
|
||||||
const [iz, setIz] = useState({ deger, yon: 1 });
|
|
||||||
if (iz.deger !== deger) setIz({ deger, yon: deger > iz.deger ? 1 : -1 });
|
|
||||||
const yon =
|
|
||||||
sabitYon ?? (iz.deger === deger ? iz.yon : deger > iz.deger ? 1 : -1);
|
|
||||||
const bicim = bicimAl(enAzBasamak);
|
|
||||||
const metin = bicim.format(deger);
|
|
||||||
|
|
||||||
if (azalt) return <span className={className}>{metin}</span>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={cn("inline-flex", className)}>
|
|
||||||
<span className="sr-only">{metin}</span>
|
|
||||||
<span aria-hidden className="inline-flex">
|
|
||||||
<AnimatePresence initial={false} custom={yon}>
|
|
||||||
{parcala(deger, bicim).map((p) => (
|
|
||||||
<motion.span
|
|
||||||
key={p.anahtar}
|
|
||||||
custom={yon}
|
|
||||||
variants={yuva}
|
|
||||||
initial="giris"
|
|
||||||
animate="merkez"
|
|
||||||
exit="cikis"
|
|
||||||
className="relative inline-block overflow-hidden"
|
|
||||||
>
|
|
||||||
{"rakam" in p ? <Tekerlek rakam={p.rakam} yon={yon} /> : p.metin}
|
|
||||||
</motion.span>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Tek seçimli hap grubu (puan türü, tema): seçili vurgu ayrı ayrı yeniden
|
|
||||||
// boyanmak yerine TEK bir yüzey olarak seçenekten seçeneğe kayar
|
|
||||||
// (`layoutId`). Göz "hangisi seçili" sorusunu hareketin bittiği yerde okur.
|
|
||||||
//
|
|
||||||
// Kaynak: Arc UI "Chip group" / "Floating button group" (uiarc.dev) kayan
|
|
||||||
// vurgu deseni; sınıf dizisi DESIGN.md hap dili. İki varyant:
|
|
||||||
// - `ayrik`: her seçenek kendi kenarlıklı hapı, vurgu mavi dolu hap (hero ve
|
|
||||||
// CTA formundaki puan türü çipleri).
|
|
||||||
// - `kapsul`: tek kapsül içinde koyu hap (üst çubuk "Giriş yap" diliyle aynı,
|
|
||||||
// tema seçici). Koyu hap yazısı `text-slate-50` (§2.3).
|
|
||||||
// Yay §6 varsayılanı; hareketi azalt'ta vurgu kaymaz, yerinde belirir.
|
|
||||||
|
|
||||||
import { useId, type ComponentType } from "react";
|
|
||||||
import { motion, useReducedMotion } from "motion/react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
|
|
||||||
|
|
||||||
export interface SecimHapiSecenek<T extends string> {
|
|
||||||
deger: T;
|
|
||||||
etiket: string;
|
|
||||||
ikon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecimHaplari<T extends string>({
|
|
||||||
secenekler,
|
|
||||||
deger,
|
|
||||||
onChange,
|
|
||||||
ariaLabel,
|
|
||||||
varyant = "ayrik",
|
|
||||||
boyut = "sm",
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
secenekler: readonly SecimHapiSecenek<T>[];
|
|
||||||
/** `null`: henüz seçim yok (ör. tema hidrasyondan önce) — vurgu çizilmez. */
|
|
||||||
deger: T | null;
|
|
||||||
onChange: (deger: T) => void;
|
|
||||||
ariaLabel: string;
|
|
||||||
varyant?: "ayrik" | "kapsul";
|
|
||||||
boyut?: "sm" | "md";
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const uid = useId();
|
|
||||||
const azalt = useReducedMotion();
|
|
||||||
const kapsul = varyant === "kapsul";
|
|
||||||
const yukseklik = boyut === "md" ? "h-9 px-3 text-sm" : "h-8 px-3 text-xs";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-wrap",
|
|
||||||
kapsul
|
|
||||||
? "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1"
|
|
||||||
: "gap-1.5",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{secenekler.map(({ deger: d, etiket, ikon: Ikon }) => {
|
|
||||||
const secili = deger === d;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={d}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={secili}
|
|
||||||
onClick={() => onChange(d)}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex cursor-pointer items-center gap-1.5 rounded-full font-semibold outline-none transition-[color,border-color,background-color,transform] duration-200 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
|
|
||||||
yukseklik,
|
|
||||||
kapsul
|
|
||||||
? secili
|
|
||||||
? "text-slate-50"
|
|
||||||
: "text-slate-600 hover:text-orange-500"
|
|
||||||
: secili
|
|
||||||
? "border border-transparent text-white"
|
|
||||||
: "border border-slate-200 bg-card text-slate-600 hover:border-slate-300 hover:bg-slate-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{secili ? (
|
|
||||||
<motion.span
|
|
||||||
layoutId={`${uid}-vurgu`}
|
|
||||||
initial={false}
|
|
||||||
transition={azalt ? { duration: 0 } : YAY}
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 rounded-full",
|
|
||||||
kapsul ? "bg-slate-900" : "bg-primary",
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{Ikon ? (
|
|
||||||
<Ikon className="relative size-4 shrink-0" aria-hidden />
|
|
||||||
) : null}
|
|
||||||
<span className="relative">{etiket}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
54
src/components/ui/sonner.tsx
Normal file
54
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"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 }
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,13 @@ import { useLayoutEffect, useRef, type ReactNode } from "react";
|
|||||||
import { animate, useReducedMotion } from "motion/react";
|
import { animate, useReducedMotion } from "motion/react";
|
||||||
import { ChevronRight, GraduationCap, X } from "lucide-react";
|
import { ChevronRight, GraduationCap, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AltSayfa,
|
Drawer,
|
||||||
AltSayfaKapat,
|
DrawerClose,
|
||||||
AltSayfaGovde,
|
DrawerContent,
|
||||||
AltSayfaIcerik,
|
DrawerDescription,
|
||||||
AltSayfaAciklama,
|
DrawerTitle,
|
||||||
AltSayfaBaslik,
|
DrawerTrigger,
|
||||||
AltSayfaTetik,
|
} from "@/components/ui/drawer";
|
||||||
} from "@/components/ui/alt-sayfa";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı
|
* Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı
|
||||||
@@ -39,8 +38,8 @@ export function AkademikAyrinti({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AltSayfa>
|
<Drawer handleOnly>
|
||||||
<AltSayfaTetik asChild>
|
<DrawerTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
||||||
@@ -63,23 +62,23 @@ export function AkademikAyrinti({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</AltSayfaTetik>
|
</DrawerTrigger>
|
||||||
<AltSayfaIcerik>
|
<DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl">
|
||||||
<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]">
|
<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 />
|
<X className="size-4" aria-hidden />
|
||||||
<span className="sr-only">Kapat</span>
|
<span className="sr-only">Kapat</span>
|
||||||
</AltSayfaKapat>
|
</DrawerClose>
|
||||||
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
<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">
|
||||||
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||||
{baslik}
|
{baslik}
|
||||||
</AltSayfaBaslik>
|
</DrawerTitle>
|
||||||
<AltSayfaAciklama className="mt-1 px-10 text-center text-sm text-slate-500">
|
<DrawerDescription className="mt-1 px-10 text-center text-sm text-slate-500">
|
||||||
{baglam}
|
{baglam}
|
||||||
</AltSayfaAciklama>
|
</DrawerDescription>
|
||||||
<AcilisSahnesi>{children}</AcilisSahnesi>
|
<AcilisSahnesi>{children}</AcilisSahnesi>
|
||||||
</AltSayfaGovde>
|
</div>
|
||||||
</AltSayfaIcerik>
|
</DrawerContent>
|
||||||
</AltSayfa>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Armchair,
|
Armchair,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Calculator,
|
Calculator,
|
||||||
|
ChartLine,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Landmark,
|
Landmark,
|
||||||
@@ -34,7 +35,11 @@ import { VeriKaynagi } from "./veri-kaynagi";
|
|||||||
import { NavBaslik } from "./nav-baslik";
|
import { NavBaslik } from "./nav-baslik";
|
||||||
import { AkademikAyrinti } from "./akademik-ayrinti";
|
import { AkademikAyrinti } from "./akademik-ayrinti";
|
||||||
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
|
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
|
||||||
import { KartBaglantisi, kisaUniAdi } from "./taban-karti";
|
import {
|
||||||
|
KartBaglantisi,
|
||||||
|
kisaUniAdi,
|
||||||
|
ortaTabanAciklamasi,
|
||||||
|
} from "./taban-karti";
|
||||||
import { trBaslikDuzeni } from "@/lib/slug";
|
import { trBaslikDuzeni } from "@/lib/slug";
|
||||||
import {
|
import {
|
||||||
BOLUM_ISTIHDAM_KAYNAK_URL,
|
BOLUM_ISTIHDAM_KAYNAK_URL,
|
||||||
@@ -101,7 +106,7 @@ function MezunIstihdami({
|
|||||||
const istihdamKaresi =
|
const istihdamKaresi =
|
||||||
veri.istihdam != null ? (
|
veri.istihdam != null ? (
|
||||||
<div data-kare="sol" className={`${kare} min-h-40 bg-orange-500 py-4`}>
|
<div data-kare="sol" className={`${kare} min-h-40 bg-orange-500 py-4`}>
|
||||||
<dt className={etiket}>İşe yerleşen</dt>
|
<dt className={etiket}>Kayıtlı çalışan</dt>
|
||||||
<dd className={`mt-3 ${devSayi}`}>%{ondalik(veri.istihdam)}</dd>
|
<dd className={`mt-3 ${devSayi}`}>%{ondalik(veri.istihdam)}</dd>
|
||||||
{ort.istihdam != null ? (
|
{ort.istihdam != null ? (
|
||||||
<dd className="mt-2 text-base font-bold text-white/90">
|
<dd className="mt-2 text-base font-bold text-white/90">
|
||||||
@@ -160,7 +165,7 @@ function MezunIstihdami({
|
|||||||
>
|
>
|
||||||
Türkiye geneli, tüm üniversitelerin{" "}
|
Türkiye geneli, tüm üniversitelerin{" "}
|
||||||
{duzey === "lisans" ? "lisans" : "ön lisans"} mezunları birlikte;
|
{duzey === "lisans" ? "lisans" : "ön lisans"} mezunları birlikte;
|
||||||
üniversiteye göre değişir. İşe yerleşen, sigortalı bir işte
|
üniversiteye göre değişir. Kayıtlı çalışan, sigortalı bir işte
|
||||||
çalışanların oranıdır. Kazanç grubu bölümler arası kıyastır, maaş
|
çalışanların oranıdır. Kazanç grubu bölümler arası kıyastır, maaş
|
||||||
değildir.
|
değildir.
|
||||||
</p>
|
</p>
|
||||||
@@ -404,11 +409,10 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
|||||||
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
|
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
|
||||||
];
|
];
|
||||||
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
|
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
|
||||||
// çekmiyor): öğrencinin sorusuna cevap veren üç sayı — en yüksek taban,
|
// çekmiyor): öğrencinin sorusuna cevap veren dört sayı — en seçici
|
||||||
// devlette en düşük taban ve boş kalan kontenjan. Orta taban kartı
|
// program, devlette en geniş kapı, orta tabanın yıllar içindeki seyri ve
|
||||||
// kaldırıldı (Bilal, 6 Eki 2026). Hesap lib/taban-ozeti.ts; program/
|
// boş kalan koltuk. Hesap lib/taban-ozeti.ts; program/devlet/vakıf sayıları
|
||||||
// devlet/vakıf sayıları özet paragrafta kalır. Üniversite sayfasıyla aynı
|
// özet paragrafta kalır. Üniversite sayfasıyla aynı düzen (DESIGN.md §4.8).
|
||||||
// kartlar (DESIGN.md §4.8).
|
|
||||||
const { taban } = bolum;
|
const { taban } = bolum;
|
||||||
const uniBaglantisi = (universite: string) => (
|
const uniBaglantisi = (universite: string) => (
|
||||||
<KartBaglantisi
|
<KartBaglantisi
|
||||||
@@ -550,14 +554,13 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
|||||||
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
|
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8); burada üç.
|
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin
|
||||||
Telefonda iki sütunda tek kalan üçüncü kart satırı boylu boyunca
|
sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
|
||||||
alır. Derin sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
|
|
||||||
{ilkSayfa ? (
|
{ilkSayfa ? (
|
||||||
<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">
|
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<OzetKart
|
<OzetKart
|
||||||
ikon={Target}
|
ikon={Target}
|
||||||
etiket="En yüksek taban"
|
etiket="En seçici"
|
||||||
{...(taban.enSecici
|
{...(taban.enSecici
|
||||||
? kartSayisi(taban.enSecici.sira)
|
? kartSayisi(taban.enSecici.sira)
|
||||||
: { deger: "—" })}
|
: { deger: "—" })}
|
||||||
@@ -572,11 +575,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Etiket kısa kalır ("Devlette en düşük" 131 px'te iki satıra
|
{/* Etiket kısa kalır ("Devlette en geniş" 131 px'te iki satıra
|
||||||
kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
|
kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
|
||||||
<OzetKart
|
<OzetKart
|
||||||
ikon={Landmark}
|
ikon={Landmark}
|
||||||
etiket="En düşük taban"
|
etiket="En geniş kapı"
|
||||||
{...(taban.enGenis
|
{...(taban.enGenis
|
||||||
? kartSayisi(taban.enGenis.sira)
|
? kartSayisi(taban.enGenis.sira)
|
||||||
: { deger: "—" })}
|
: { deger: "—" })}
|
||||||
@@ -592,9 +595,18 @@ 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
|
<OzetKart
|
||||||
ikon={Armchair}
|
ikon={Armchair}
|
||||||
etiket="Boş kontenjan"
|
etiket="Boş koltuk"
|
||||||
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
|
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
|
||||||
alt={
|
alt={
|
||||||
taban.doluluk
|
taban.doluluk
|
||||||
|
|||||||
@@ -37,14 +37,13 @@ import { UNITUR_ETIKET } from "@/lib/unitur";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SON_YIL } from "@/lib/veri-yillari";
|
import { SON_YIL } from "@/lib/veri-yillari";
|
||||||
import {
|
import {
|
||||||
AltSayfa,
|
Drawer,
|
||||||
AltSayfaKapat,
|
DrawerClose,
|
||||||
AltSayfaGovde,
|
DrawerContent,
|
||||||
AltSayfaIcerik,
|
DrawerDescription,
|
||||||
AltSayfaAciklama,
|
DrawerTitle,
|
||||||
AltSayfaBaslik,
|
DrawerTrigger,
|
||||||
AltSayfaTetik,
|
} from "@/components/ui/drawer";
|
||||||
} from "@/components/ui/alt-sayfa";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -138,8 +137,8 @@ export function ProgramAyrintiButonu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AltSayfa onOpenChange={acildi}>
|
<Drawer onOpenChange={acildi} handleOnly>
|
||||||
<AltSayfaTetik asChild>
|
<DrawerTrigger asChild>
|
||||||
{varyant === "metin" ? (
|
{varyant === "metin" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,24 +160,21 @@ export function ProgramAyrintiButonu({
|
|||||||
<span className="sr-only">{isim} — program ayrıntısı</span>
|
<span className="sr-only">{isim} — program ayrıntısı</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</AltSayfaTetik>
|
</DrawerTrigger>
|
||||||
{/* Alt sayfa kabı tek kaynak (alt-sayfa.tsx), çarpı §4.3. İçerik uzun:
|
{/* Çekmece kabı ve çarpı cta-sira-form.tsx'teki çekmeceyle birebir (§4.10, §4.3) */}
|
||||||
sayfa ekranın %60'ında açılır (başlık + künye), yukarı çekince ya da
|
<DrawerContent
|
||||||
kaydırınca tam boya çıkar (§4.19). */}
|
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||||||
<AltSayfaIcerik
|
|
||||||
duraklar={[0.6, 0.92]}
|
|
||||||
ilkDurak={0}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<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]">
|
<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 />
|
<X className="size-4" aria-hidden />
|
||||||
<span className="sr-only">Kapat</span>
|
<span className="sr-only">Kapat</span>
|
||||||
</AltSayfaKapat>
|
</DrawerClose>
|
||||||
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
<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">
|
||||||
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||||
{isim}
|
{isim}
|
||||||
</AltSayfaBaslik>
|
</DrawerTitle>
|
||||||
<AltSayfaAciklama className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
|
<DrawerDescription className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
|
||||||
{veri
|
{veri
|
||||||
? [
|
? [
|
||||||
trBaslikDuzeni(veri.program.universite),
|
trBaslikDuzeni(veri.program.universite),
|
||||||
@@ -188,13 +184,13 @@ export function ProgramAyrintiButonu({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")
|
.join(" · ")
|
||||||
: (universite ?? "Programın tüm açık verisi")}
|
: (universite ?? "Programın tüm açık verisi")}
|
||||||
</AltSayfaAciklama>
|
</DrawerDescription>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<AyrintiIcerik durum={durum} veri={veri} />
|
<AyrintiIcerik durum={durum} veri={veri} />
|
||||||
</div>
|
</div>
|
||||||
</AltSayfaGovde>
|
</div>
|
||||||
</AltSayfaIcerik>
|
</DrawerContent>
|
||||||
</AltSayfa>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,47 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
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
|
* Ü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
|
* yardımcıları (DESIGN.md §4.8). Yıl yıl ayrıntı çekmecesi ("Yıllara göre
|
||||||
* taban") ve orta taban kartı 6 Eki 2026'da Bilal'in isteğiyle kaldırıldı;
|
* taban") 6 Eki 2026'da Bilal'in isteğiyle şimdilik kaldırıldı; geri gelirse
|
||||||
* geri gelirse git geçmişinde `taban-yillari.tsx`, `siranla-kapilar.tsx` ve
|
* git geçmişinde `taban-yillari.tsx` + `siranla-kapilar.tsx` olarak duruyor.
|
||||||
* `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ğ). */
|
/** Özet kartı açıklamasındaki ad linki (üniversite ↔ bölüm çapraz bağ). */
|
||||||
export function KartBaglantisi({
|
export function KartBaglantisi({
|
||||||
href,
|
href,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Armchair,
|
Armchair,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Building2,
|
Building2,
|
||||||
|
ChartLine,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Landmark,
|
Landmark,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
@@ -56,7 +57,7 @@ import { UrapLogo } from "./urap-logo";
|
|||||||
import { AkademikAyrinti } from "./akademik-ayrinti";
|
import { AkademikAyrinti } from "./akademik-ayrinti";
|
||||||
import { NavBaslik } from "./nav-baslik";
|
import { NavBaslik } from "./nav-baslik";
|
||||||
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
|
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
|
||||||
import { KartBaglantisi } from "./taban-karti";
|
import { KartBaglantisi, ortaTabanAciklamasi } from "./taban-karti";
|
||||||
import { bolumBazAdi } from "@/lib/slug";
|
import { bolumBazAdi } from "@/lib/slug";
|
||||||
import { uniLogoYolu } from "@/lib/uni-logolar";
|
import { uniLogoYolu } from "@/lib/uni-logolar";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -677,15 +678,14 @@ export function UniversiteIcerik({
|
|||||||
{/* Şablon tam 4 hücre bırakıyor: masaüstünde harita 3. sütunu
|
{/* Ş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
|
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Dört
|
||||||
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
|
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
|
||||||
program/kontenjan adedi ilgi çekmiyor): en yüksek ve en
|
program/kontenjan adedi ilgi çekmiyor): en seçici bölüm
|
||||||
düşük taban bölüm adıyla, doluluk ve Türkiye sırası. Orta
|
adıyla, orta tabanın yıllar içindeki seyri, doluluk ve
|
||||||
taban kartı kaldırıldı (Bilal, 6 Eki 2026). Program, birim
|
Türkiye sırası. Program, birim ve kontenjan sayıları özet
|
||||||
ve kontenjan sayıları özet paragrafta. Hesap
|
paragrafta. Hesap lib/taban-ozeti.ts. */}
|
||||||
lib/taban-ozeti.ts. */}
|
|
||||||
<dl className="contents">
|
<dl className="contents">
|
||||||
<OzetKart
|
<OzetKart
|
||||||
ikon={Target}
|
ikon={Target}
|
||||||
etiket="En yüksek taban"
|
etiket="En seçici bölüm"
|
||||||
{...(taban.enSecici
|
{...(taban.enSecici
|
||||||
? kartSayisi(taban.enSecici.sira)
|
? kartSayisi(taban.enSecici.sira)
|
||||||
: { deger: "—" })}
|
: { deger: "—" })}
|
||||||
@@ -708,28 +708,15 @@ export function UniversiteIcerik({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<OzetKart
|
<OzetKart
|
||||||
ikon={Landmark}
|
ikon={ChartLine}
|
||||||
etiket="En düşük taban"
|
etiket="Orta taban"
|
||||||
{...(taban.enGenis
|
{...(() => {
|
||||||
? kartSayisi(taban.enGenis.sira)
|
const son = [...taban.ortaTaban]
|
||||||
: { deger: "—" })}
|
.reverse()
|
||||||
alt={
|
.find((v) => v != null);
|
||||||
taban.enGenis ? (
|
return son != null ? kartSayisi(son) : { deger: "—" };
|
||||||
<>
|
|
||||||
<KartBaglantisi
|
|
||||||
href={(() => {
|
|
||||||
const s = bolumSlugFromIsim(taban.enGenis.isim);
|
|
||||||
return s ? `/bolum/${s}` : null;
|
|
||||||
})()}
|
})()}
|
||||||
>
|
alt={ortaTabanAciklamasi(taban)}
|
||||||
{bolumBazAdi(taban.enGenis.isim)}
|
|
||||||
</KartBaglantisi>{" "}
|
|
||||||
· {SON_YIL} taban sırası
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"henüz taban oluşmadı"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{taban.doluluk ? (
|
{taban.doluluk ? (
|
||||||
<OzetKart
|
<OzetKart
|
||||||
@@ -738,7 +725,7 @@ export function UniversiteIcerik({
|
|||||||
deger={`%${taban.doluluk.yuzde}`}
|
deger={`%${taban.doluluk.yuzde}`}
|
||||||
alt={
|
alt={
|
||||||
taban.doluluk.bosKoltuk > 0
|
taban.doluluk.bosKoltuk > 0
|
||||||
? `${sayi(taban.doluluk.kontenjan)} kontenjanın ${sayi(taban.doluluk.bosKoltuk)} tanesi boş kaldı · ${SON_YIL}`
|
? `${sayi(taban.doluluk.kontenjan)} kontenjandan ${sayi(taban.doluluk.bosKoltuk)} koltuk boş kaldı · ${SON_YIL}`
|
||||||
: `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
|
: `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowRight, MailCheck } from "lucide-react";
|
import { ArrowRight, MailCheck } from "lucide-react";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -130,15 +129,11 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
|||||||
disabled={busy !== null || bekleme > 0}
|
disabled={busy !== null || bekleme > 0}
|
||||||
className="cursor-pointer font-medium text-primary underline-offset-2 hover:underline disabled:cursor-default disabled:text-slate-400 disabled:no-underline"
|
className="cursor-pointer font-medium text-primary underline-offset-2 hover:underline disabled:cursor-default disabled:text-slate-400 disabled:no-underline"
|
||||||
>
|
>
|
||||||
{busy === "email" ? (
|
{busy === "email"
|
||||||
"Gönderiliyor…"
|
? "Gönderiliyor…"
|
||||||
) : bekleme > 0 ? (
|
: bekleme > 0
|
||||||
<>
|
? `Yeniden gönder (${bekleme})`
|
||||||
Yeniden gönder (<SayiRulo deger={bekleme} />)
|
: "Yeniden gönder"}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Yeniden gönder"
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import { ClipboardList, Coins } from "lucide-react";
|
import { ClipboardList, Coins } from "lucide-react";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { ListemButonu } from "@/features/liste/components/listem-butonu";
|
import { ListemButonu } from "@/features/liste/components/listem-butonu";
|
||||||
@@ -70,9 +69,7 @@ export async function UserNav() {
|
|||||||
title="Kredilerin — yüklemek için tıkla"
|
title="Kredilerin — yüklemek için tıkla"
|
||||||
>
|
>
|
||||||
<Coins className="size-3.5 text-amber-500 sm:size-4" aria-hidden />
|
<Coins className="size-3.5 text-amber-500 sm:size-4" aria-hidden />
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">{u.creditBalance}</span>
|
||||||
<SayiRulo deger={u.creditBalance} />
|
|
||||||
</span>
|
|
||||||
{/* Etiket yalnızca md–lg arası: daha darda logo kesiliyor, lg üstünde
|
{/* Etiket yalnızca md–lg arası: daha darda logo kesiliyor, lg üstünde
|
||||||
ortadaki nav ile yer dar (konteyner 1152'de sabit). İkon+sayı ve
|
ortadaki nav ile yer dar (konteyner 1152'de sabit). İkon+sayı ve
|
||||||
title her genişlikte anlamı taşıyor. */}
|
title her genişlikte anlamı taşıyor. */}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage).
|
// hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage).
|
||||||
// Exit-intent/agresif popup YOK (PRD kuralı).
|
// Exit-intent/agresif popup YOK (PRD kuralı).
|
||||||
|
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
@@ -73,7 +72,7 @@ export function KaydetBanner() {
|
|||||||
Listen yalnızca bu tarayıcıda
|
Listen yalnızca bu tarayıcıda
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm leading-snug text-slate-500">
|
<p className="text-sm leading-snug text-slate-500">
|
||||||
Giriş yap; <SayiRulo deger={liste.length} /> seçimin hesabında saklansın, her
|
Giriş yap; {liste.length} seçimin hesabında saklansın, her
|
||||||
cihazdan aç{" "}
|
cihazdan aç{" "}
|
||||||
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
|
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,34 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Manuel 24'lük listenin alt sayfası (DESIGN.md §4.10). Layout'ta bir kez
|
// Manuel 24'lük listenin alt çekmecesi (bottom drawer). Layout'ta bir kez
|
||||||
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
|
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
|
||||||
//
|
//
|
||||||
// Alt sayfa (alt-sayfa.tsx): tutamaçtan ya da başlıktan aşağı çekince, liste
|
// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır
|
||||||
// en üstteyken aşağı çekince kapanır; Escape ve perde tıklaması da kapatır.
|
// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması
|
||||||
|
// da kapatır.
|
||||||
//
|
//
|
||||||
// Satır sıralama yalnız grip tutamağından başlar (dragControls). Satırı sola
|
// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
|
||||||
// kaydırmak "Çıkar" eylemini açar, sonuna kadar çekmek uygular
|
// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları
|
||||||
// (kaydirma-eylemi.tsx, §4.23); aynı iş satırdaki "−" düğmesiyle de yapılır.
|
// sadeleşir; X butonu her durumda kalır.
|
||||||
// prefers-reduced-motion: satır animasyonları sadeleşir.
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import { CtaBandi } from "@/components/cta-bandi";
|
import { CtaBandi } from "@/components/cta-bandi";
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
import { RiskEtiketi } from "@/components/risk-etiketi";
|
import { RiskEtiketi } from "@/components/risk-etiketi";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
AltSayfa,
|
Drawer,
|
||||||
AltSayfaBaslik,
|
DrawerClose,
|
||||||
AltSayfaGovde,
|
DrawerContent,
|
||||||
AltSayfaIcerik,
|
DrawerFooter,
|
||||||
AltSayfaKapat,
|
DrawerHeader,
|
||||||
AltSayfaSurukle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/alt-sayfa";
|
} from "@/components/ui/drawer";
|
||||||
import { KaydirmaGrubu, KaydirmaSatiri } from "@/components/ui/kaydirma-eylemi";
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
@@ -44,7 +42,9 @@ import {
|
|||||||
motion,
|
motion,
|
||||||
Reorder,
|
Reorder,
|
||||||
useDragControls,
|
useDragControls,
|
||||||
useReducedMotion,
|
useMotionValue,
|
||||||
|
useTransform,
|
||||||
|
type PanInfo,
|
||||||
} from "motion/react";
|
} from "motion/react";
|
||||||
import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
|
import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
|
||||||
import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri";
|
import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri";
|
||||||
@@ -119,7 +119,7 @@ export function ListeCekmecesi() {
|
|||||||
const girisli = useOturumDurumu();
|
const girisli = useOturumDurumu();
|
||||||
const girisUrl = useGirisUrl();
|
const girisUrl = useGirisUrl();
|
||||||
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
|
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
|
||||||
// sayfa içinde erken ölçüm satırları kaydırıyordu
|
// drawer içinde erken ölçüm satırları kaydırıyordu
|
||||||
const [oturdu, setOturdu] = useState(false);
|
const [oturdu, setOturdu] = useState(false);
|
||||||
|
|
||||||
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
|
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
|
||||||
@@ -143,7 +143,7 @@ export function ListeCekmecesi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AltSayfa
|
<Drawer
|
||||||
open={acik}
|
open={acik}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (v) cekmeceAc();
|
if (v) cekmeceAc();
|
||||||
@@ -152,11 +152,16 @@ export function ListeCekmecesi() {
|
|||||||
cekmeceKapat();
|
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;
|
{/* 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
|
çarpı §4.3 kanonik sınıf setinde, temizle düğmesi aynı ölçüde
|
||||||
solda. Başlık alanından da tutulup aşağı çekilir. */}
|
solda. */}
|
||||||
|
<DrawerHeader className="relative border-b border-slate-100 px-4 pt-5 pb-4 text-center md:text-center">
|
||||||
{liste.length > 0 ? (
|
{liste.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -173,14 +178,13 @@ export function ListeCekmecesi() {
|
|||||||
<span className="sr-only">Listeyi temizle</span>
|
<span className="sr-only">Listeyi temizle</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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]">
|
<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 />
|
<X className="size-4" aria-hidden />
|
||||||
<span className="sr-only">Kapat</span>
|
<span className="sr-only">Kapat</span>
|
||||||
</AltSayfaKapat>
|
</DrawerClose>
|
||||||
<AltSayfaSurukle className="border-b border-slate-100 px-4 pt-3 pb-4 text-center">
|
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||||
<AltSayfaBaslik className="px-10 text-center">
|
|
||||||
Kendi Listem
|
Kendi Listem
|
||||||
</AltSayfaBaslik>
|
</DrawerTitle>
|
||||||
{/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3). Bilal'in
|
{/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3). Bilal'in
|
||||||
kararı (24 Eyl 2026): buradaki piksel liste başlığı çok dolu
|
kararı (24 Eyl 2026): buradaki piksel liste başlığı çok dolu
|
||||||
gösteriyordu; ilerleme çubuğuna geri dönüldü. */}
|
gösteriyordu; ilerleme çubuğuna geri dönüldü. */}
|
||||||
@@ -194,7 +198,7 @@ export function ListeCekmecesi() {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SayiRulo deger={liste.length} /> / {MANUEL_LISTE_MAX} tercih
|
{liste.length} / {MANUEL_LISTE_MAX} tercih
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -233,9 +237,11 @@ export function ListeCekmecesi() {
|
|||||||
Güvenli yok; alta birkaç tane eklemek riski azaltır.
|
Güvenli yok; alta birkaç tane eklemek riski azaltır.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</AltSayfaSurukle>
|
</DrawerHeader>
|
||||||
|
|
||||||
<AltSayfaGovde className="overflow-x-hidden px-4 py-2">
|
<div
|
||||||
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-2"
|
||||||
|
>
|
||||||
{liste.length === 0 ? (
|
{liste.length === 0 ? (
|
||||||
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
|
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
|
||||||
<ListChecks
|
<ListChecks
|
||||||
@@ -251,7 +257,6 @@ export function ListeCekmecesi() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<KaydirmaGrubu>
|
|
||||||
<Reorder.Group
|
<Reorder.Group
|
||||||
axis="y"
|
axis="y"
|
||||||
values={liste}
|
values={liste}
|
||||||
@@ -270,7 +275,6 @@ export function ListeCekmecesi() {
|
|||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</Reorder.Group>
|
</Reorder.Group>
|
||||||
</KaydirmaGrubu>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{liste.length < MANUEL_LISTE_MAX ? (
|
{liste.length < MANUEL_LISTE_MAX ? (
|
||||||
@@ -285,9 +289,9 @@ export function ListeCekmecesi() {
|
|||||||
Program ekle
|
Program ekle
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</AltSayfaGovde>
|
</div>
|
||||||
|
|
||||||
<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))]">
|
<DrawerFooter className="border-t border-slate-100 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||||
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
|
{/* 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. */}
|
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
|
||||||
{girisli === false ? (
|
{girisli === false ? (
|
||||||
@@ -368,12 +372,15 @@ export function ListeCekmecesi() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CtaBandi>
|
</CtaBandi>
|
||||||
</div>
|
</DrawerFooter>
|
||||||
</AltSayfaIcerik>
|
</DrawerContent>
|
||||||
</AltSayfa>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
|
||||||
|
const SIL_ESIK = 90;
|
||||||
|
|
||||||
function CekmeceSatiri({
|
function CekmeceSatiri({
|
||||||
tercih,
|
tercih,
|
||||||
sira,
|
sira,
|
||||||
@@ -384,9 +391,19 @@ function CekmeceSatiri({
|
|||||||
projeksiyonAcik: boolean;
|
projeksiyonAcik: boolean;
|
||||||
}) {
|
}) {
|
||||||
const siraKontrol = useDragControls();
|
const siraKontrol = useDragControls();
|
||||||
const azalt = useReducedMotion();
|
|
||||||
const cikarIste = () =>
|
// 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ı`);
|
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Reorder.Item
|
<Reorder.Item
|
||||||
@@ -396,34 +413,36 @@ function CekmeceSatiri({
|
|||||||
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
|
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
|
||||||
dragListener={false}
|
dragListener={false}
|
||||||
dragControls={siraKontrol}
|
dragControls={siraKontrol}
|
||||||
initial={azalt ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
|
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
// Satır "Çıkar" bandının altında yana kayarak gider (KaydirmaSatiri);
|
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
|
||||||
// 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 }}
|
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
|
||||||
whileDrag={{ scale: 1.03, zIndex: 10 }}
|
whileDrag={{ scale: 1.03, zIndex: 10 }}
|
||||||
className="relative border-b border-slate-100 last:border-b-0"
|
className="relative select-none border-b border-slate-100 last:border-b-0"
|
||||||
>
|
>
|
||||||
{/* Sola kaydır: "Çıkar" açılır; sonuna kadar çekince uygulanır (§4.23) */}
|
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
|
||||||
<KaydirmaSatiri
|
<motion.div
|
||||||
sag={[
|
style={{ opacity: silGorunur }}
|
||||||
{
|
className="absolute inset-0 flex items-center bg-red-500 pl-4"
|
||||||
etiket: "Çıkar",
|
aria-hidden
|
||||||
ikon: <CircleMinus aria-hidden />,
|
>
|
||||||
ton: "tehlike",
|
<Trash2 className="size-4 text-white" />
|
||||||
onSec: cikarIste,
|
</motion.div>
|
||||||
},
|
|
||||||
]}
|
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
|
||||||
icerikSinifi="flex items-center gap-3 bg-card py-3"
|
<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"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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) => {
|
onPointerDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
|
||||||
siraKontrol.start(e);
|
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"
|
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"
|
||||||
@@ -438,6 +457,7 @@ function CekmeceSatiri({
|
|||||||
<Link
|
<Link
|
||||||
href={bolumHref(tercih.isim)}
|
href={bolumHref(tercih.isim)}
|
||||||
onClick={cekmeceKapat}
|
onClick={cekmeceKapat}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||||
className={SATIR_AD}
|
className={SATIR_AD}
|
||||||
>
|
>
|
||||||
{tercih.isim}
|
{tercih.isim}
|
||||||
@@ -447,6 +467,7 @@ function CekmeceSatiri({
|
|||||||
<Link
|
<Link
|
||||||
href={uniHref(tercih.universite)}
|
href={uniHref(tercih.universite)}
|
||||||
onClick={cekmeceKapat}
|
onClick={cekmeceKapat}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||||
title={`${tercih.universite} taban puanları sayfası`}
|
title={`${tercih.universite} taban puanları sayfası`}
|
||||||
className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
|
className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
|
||||||
>
|
>
|
||||||
@@ -469,16 +490,18 @@ function CekmeceSatiri({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={cikarIste}
|
onClick={() =>
|
||||||
|
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
|
||||||
|
}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||||
// Satırdan çıkarma "−" ile, çerçevesiz: kapatma çarpısıyla (§4.3)
|
// Satırdan çıkarma "−" ile, çerçevesiz: kapatma çarpısıyla (§4.3)
|
||||||
// karışmasın diye çarpı yalnız sayfanın kendisini kapatır. Kaydırma
|
// karışmasın diye çarpı yalnız çekmecenin kendisini kapatır.
|
||||||
// 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]"
|
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 />
|
<CircleMinus className="size-5" aria-hidden />
|
||||||
<span className="sr-only">{tercih.isim} listeden çıkar</span>
|
<span className="sr-only">{tercih.isim} listeden çıkar</span>
|
||||||
</button>
|
</button>
|
||||||
</KaydirmaSatiri>
|
</motion.div>
|
||||||
</Reorder.Item>
|
</Reorder.Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, Plus } from "lucide-react";
|
import { ArrowRight, Plus } from "lucide-react";
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import {
|
import {
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
cekmeceAc,
|
cekmeceAc,
|
||||||
@@ -42,17 +41,9 @@ export function ListeDurumuSatiri() {
|
|||||||
onClick={cekmeceAc}
|
onClick={cekmeceAc}
|
||||||
className="group inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-sm font-semibold text-slate-800 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
|
className="group inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-sm font-semibold text-slate-800 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
|
||||||
>
|
>
|
||||||
<span>
|
Listende {liste.length}/{MANUEL_LISTE_MAX} program
|
||||||
Listende <SayiRulo deger={liste.length} />/{MANUEL_LISTE_MAX} program
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-slate-500 group-hover:text-orange-500">
|
<span className="font-medium text-slate-500 group-hover:text-orange-500">
|
||||||
{kalan > 0 ? (
|
{kalan > 0 ? `· ${kalan} yer kaldı` : "· liste tamam"}
|
||||||
<>
|
|
||||||
· <SayiRulo deger={kalan} /> yer kaldı
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"· liste tamam"
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
className="size-4 transition-transform duration-150 group-hover:translate-x-0.5"
|
className="size-4 transition-transform duration-150 group-hover:translate-x-0.5"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// yaşadığı /listem sayfasına aittir — isim çakışmasın.)
|
// yaşadığı /listem sayfasına aittir — isim çakışmasın.)
|
||||||
|
|
||||||
import { ListChecks } from "lucide-react";
|
import { ListChecks } from "lucide-react";
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
import {
|
import {
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
cekmeceDegistir,
|
cekmeceDegistir,
|
||||||
@@ -17,6 +17,7 @@ import { preloadListeCekmecesi } from "./liste-cekmecesi-lazy";
|
|||||||
export function ListemButonu() {
|
export function ListemButonu() {
|
||||||
const liste = useManuelListe();
|
const liste = useManuelListe();
|
||||||
const acik = useCekmeceAcik();
|
const acik = useCekmeceAcik();
|
||||||
|
const azMotion = useReducedMotion();
|
||||||
|
|
||||||
// Liste boşken buton gürültü: tabloya (+) ulaşmamış ziyaretçiye "0/24"
|
// Liste boşken buton gürültü: tabloya (+) ulaşmamış ziyaretçiye "0/24"
|
||||||
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
|
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
|
||||||
@@ -39,11 +40,16 @@ export function ListemButonu() {
|
|||||||
aria-label anlamı taşıyor. UserNav'daki pill'le aynı desen. */}
|
aria-label anlamı taşıyor. UserNav'daki pill'le aynı desen. */}
|
||||||
<span className="hidden">Kendi Listem</span>
|
<span className="hidden">Kendi Listem</span>
|
||||||
{liste.length > 0 ? (
|
{liste.length > 0 ? (
|
||||||
// Rozet yerinde durur, rakam rulo gibi döner (eklemede yukarı,
|
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
|
||||||
// çıkarmada aşağı) — Arc "Number field" rulosu, bkz. sayi-rulo.tsx.
|
<motion.span
|
||||||
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-orange-500 px-1.5 font-heading text-xs font-bold text-white">
|
key={liste.length}
|
||||||
<SayiRulo deger={liste.length} />
|
initial={azMotion ? false : { scale: 0.6 }}
|
||||||
</span>
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", duration: 0.4, bounce: 0.45 }}
|
||||||
|
className="flex h-6 min-w-6 items-center justify-center rounded-full bg-orange-500 px-1.5 font-heading text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
{liste.length}
|
||||||
|
</motion.span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
|
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
|
||||||
import { ArrowRight, Check, Plus } from "lucide-react";
|
import { ArrowRight, Check, Plus } from "lucide-react";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import type { Program } from "@/types/yokatlas";
|
import type { Program } from "@/types/yokatlas";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import type {
|
|||||||
} from "@/types/yokatlas";
|
} from "@/types/yokatlas";
|
||||||
import { DILIM_ETIKET, riskHesapla } from "@/lib/risk";
|
import { DILIM_ETIKET, riskHesapla } from "@/lib/risk";
|
||||||
import { bolumSayfaSlug, ilSayfaHref, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug";
|
import { bolumSayfaSlug, ilSayfaHref, trBaslikDuzeni, uniSayfaSlug } from "@/lib/slug";
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import { TurCipi } from "@/components/tur-cipi";
|
import { TurCipi } from "@/components/tur-cipi";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
|
import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
|
||||||
@@ -307,7 +306,7 @@ export function ProgramTablosu({
|
|||||||
<ChevronDown className="size-4" aria-hidden />
|
<ChevronDown className="size-4" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm tabular-nums text-slate-500">
|
<span className="text-sm tabular-nums text-slate-500">
|
||||||
<SayiRulo deger={satirlar.length} /> /{" "}
|
{satirlar.length.toLocaleString("tr-TR")} /{" "}
|
||||||
{toplam.toLocaleString("tr-TR")} program
|
{toplam.toLocaleString("tr-TR")} program
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { ArrowRight, Trophy } from "lucide-react";
|
import { ArrowRight, Trophy } from "lucide-react";
|
||||||
import { useReducedMotion } from "motion/react";
|
import { useReducedMotion } from "motion/react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useLayoutEffect, useState, useTransition } from "react";
|
import { useLayoutEffect, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, Loader2 } from "lucide-react";
|
import { ArrowRight, Loader2 } from "lucide-react";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import { baslatOdeme } from "../odeme-actions";
|
import { baslatOdeme } from "../odeme-actions";
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
"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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,12 +41,8 @@ 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" },
|
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" },
|
kapanan_program_ara: { surer: "Kapanan programlara bakıyorum", bitti: "Kapanan programlara baktım" },
|
||||||
profil_guncelle: { surer: "Tercihini kaydediyorum", bitti: "Tercihini kaydettim" },
|
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" };
|
const VARSAYILAN_ETIKET = { surer: "Veriye bakıyorum", bitti: "Veriye baktım" };
|
||||||
|
|
||||||
/** Hapın metni; hata renkle değil sözle söylenir (§2.2). */
|
/** Hapın metni; hata renkle değil sözle söylenir (§2.2). */
|
||||||
@@ -54,9 +50,7 @@ export function aracPiliMetni(p: AracPili): string {
|
|||||||
const e = ARAC_ETIKETI[p.arac] ?? VARSAYILAN_ETIKET;
|
const e = ARAC_ETIKETI[p.arac] ?? VARSAYILAN_ETIKET;
|
||||||
const sayi = p.sayi > 1 ? ` ×${p.sayi}` : "";
|
const sayi = p.sayi > 1 ? ` ×${p.sayi}` : "";
|
||||||
if (p.durum === "calisiyor") return `${e.surer}${sayi}…`;
|
if (p.durum === "calisiyor") return `${e.surer}${sayi}…`;
|
||||||
if (p.durum === "hata") {
|
if (p.durum === "hata") return `${e.bitti}${sayi} (veri gelmedi)`;
|
||||||
return p.arac === "liste_revize" ? `Listeni yeniden kuramadım${sayi}` : `${e.bitti}${sayi} (veri gelmedi)`;
|
|
||||||
}
|
|
||||||
return `${e.bitti}${sayi}`;
|
return `${e.bitti}${sayi}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +65,6 @@ export function aracPilleri(m: UIMessage): AracPili[] {
|
|||||||
for (const p of m.parts) {
|
for (const p of m.parts) {
|
||||||
if (!p.type.startsWith("tool-") || !("state" in p)) continue;
|
if (!p.type.startsWith("tool-") || !("state" in p)) continue;
|
||||||
const arac = p.type.slice("tool-".length);
|
const arac = p.type.slice("tool-".length);
|
||||||
if (HAPSIZ_ARACLAR.has(arac)) continue;
|
|
||||||
let t = toplam.get(arac);
|
let t = toplam.get(arac);
|
||||||
if (!t) {
|
if (!t) {
|
||||||
t = { calisiyor: 0, hata: 0, sayi: 0 };
|
t = { calisiyor: 0, hata: 0, sayi: 0 };
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { BolmeTutamaci } from "@/components/ui/bolme-tutamaci";
|
|
||||||
import { RaporListesi } from "./rapor-listesi";
|
import { RaporListesi } from "./rapor-listesi";
|
||||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||||
import { tercihFarki } from "@/features/rapor/tercih-farki";
|
import { tercihFarki } from "@/features/rapor/tercih-farki";
|
||||||
@@ -32,12 +31,12 @@ import type { RaporParams, RaporSonuc } from "@/features/rapor/types/rapor";
|
|||||||
import {
|
import {
|
||||||
SohbetClient,
|
SohbetClient,
|
||||||
SOHBET_GENISLEME_GECISI,
|
SOHBET_GENISLEME_GECISI,
|
||||||
sohbetiOdakla,
|
|
||||||
type DanismanOzeti,
|
type DanismanOzeti,
|
||||||
type IlkTercihYorumu,
|
type IlkTercihYorumu,
|
||||||
type Mesaj,
|
type Mesaj,
|
||||||
type ProfilSatiri,
|
type ProfilSatiri,
|
||||||
} from "./sohbet-client";
|
} from "./sohbet-client";
|
||||||
|
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||||
import {
|
import {
|
||||||
profilDuzenlemeyiAc,
|
profilDuzenlemeyiAc,
|
||||||
useTercihProfili,
|
useTercihProfili,
|
||||||
@@ -47,17 +46,6 @@ import { ozelDurumOzeti } from "@/lib/ozel-durum";
|
|||||||
import { PUAN_TURLERI } from "@/types/yokatlas";
|
import { PUAN_TURLERI } from "@/types/yokatlas";
|
||||||
import { DILIM_ETIKET } from "@/lib/risk";
|
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)
|
// Başlık satırındaki ikincil düğmeler: 44 px, hover tek turuncu (§4.1, §2.1)
|
||||||
const IKINCIL_BUTON =
|
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]";
|
"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]";
|
||||||
@@ -113,20 +101,22 @@ export function ListemGovde({
|
|||||||
// yeniden akar. Telefonda (lg altı) ızgara tek sütun, yalnız boy değişir.
|
// yeniden akar. Telefonda (lg altı) ızgara tek sütun, yalnız boy değişir.
|
||||||
const [sohbetGenis, setSohbetGenis] = useState(false);
|
const [sohbetGenis, setSohbetGenis] = useState(false);
|
||||||
const izgaraRef = useRef<HTMLDivElement>(null);
|
const izgaraRef = useRef<HTMLDivElement>(null);
|
||||||
const sohbetSutunuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const hareketAz = useReducedMotion();
|
const hareketAz = useReducedMotion();
|
||||||
|
|
||||||
/** Sohbet sütununu yayla hedef genişliğe götürür; `null` sınıftaki varsayılana döner. */
|
function sohbetGenisligiAyarla(genis: boolean) {
|
||||||
function kolonaGit(hedefPx: number | null) {
|
setSohbetGenis(genis);
|
||||||
const el = izgaraRef.current;
|
const el = izgaraRef.current;
|
||||||
if (!el || !window.matchMedia("(min-width: 1024px)").matches) return;
|
if (!el || !window.matchMedia("(min-width: 1024px)").matches) return;
|
||||||
const taban = kolonTabani();
|
// Dar sütun genişliği ızgara sınıflarıyla aynı: lg 380, xl 440
|
||||||
const hedef = hedefPx ?? taban;
|
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 simdiki = parseFloat(el.style.getPropertyValue("--sohbet-kolon")) || taban;
|
const simdiki = parseFloat(el.style.getPropertyValue("--sohbet-kolon")) || taban;
|
||||||
const bitir = () => {
|
const bitir = () => {
|
||||||
// Varsayılanda satır içi değer kalkar; kırılma noktası değişince sınıf geçerli olur
|
// Dar hâlde 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");
|
if (genis) el.style.setProperty("--sohbet-kolon", `${hedef}px`);
|
||||||
else el.style.setProperty("--sohbet-kolon", `${hedef}px`);
|
else el.style.removeProperty("--sohbet-kolon");
|
||||||
};
|
};
|
||||||
if (hareketAz) {
|
if (hareketAz) {
|
||||||
bitir();
|
bitir();
|
||||||
@@ -140,17 +130,6 @@ export function ListemGovde({
|
|||||||
).then(bitir);
|
).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(
|
const pinVerisi = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pinleriTuret(
|
pinleriTuret(
|
||||||
@@ -214,14 +193,9 @@ export function ListemGovde({
|
|||||||
if (params.ozelDurum?.length) {
|
if (params.ozelDurum?.length) {
|
||||||
satirlar.push(`Özel kontenjan durumun: ${ozelDurumOzeti(params.ozelDurum)}`);
|
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");
|
satirlar.push("Bu sohbetteki son mesajların");
|
||||||
return satirlar;
|
return satirlar;
|
||||||
}, [adaySira, params, hasPaket, acikSatir, kalanHak]);
|
}, [adaySira, params, hasPaket, acikSatir]);
|
||||||
|
|
||||||
// Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez).
|
// 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ı
|
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
|
||||||
@@ -238,12 +212,9 @@ export function ListemGovde({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Sohbet sütunu `--sohbet-kolon` ile (lg 380, xl 440 px = kolonTabani);
|
// 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),
|
// "Genişlet" satır içi değişkeni yayla büyütür (sohbetGenisligiAyarla), liste
|
||||||
// aradaki tutamaç elle sürükletir (§4.25); liste ve harita daralan 1fr
|
// ve harita daralan 1fr sütunda kendini yeniden akıtır. DESIGN.md §4.20.
|
||||||
// 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
|
<div
|
||||||
ref={izgaraRef}
|
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]"
|
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]"
|
||||||
@@ -328,21 +299,16 @@ export function ListemGovde({
|
|||||||
)}
|
)}
|
||||||
{hasPaket ? (
|
{hasPaket ? (
|
||||||
<>
|
<>
|
||||||
{/* 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
|
<Button
|
||||||
type="button"
|
asChild
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => sohbetiOdakla("Listemde şunu değiştir: ")}
|
|
||||||
className={IKINCIL_BUTON}
|
className={IKINCIL_BUTON}
|
||||||
>
|
>
|
||||||
|
<Link href="#revizyon">
|
||||||
<PencilLine className="size-4" aria-hidden />
|
<PencilLine className="size-4" aria-hidden />
|
||||||
Revize et
|
Revize et
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -405,38 +371,11 @@ export function ListemGovde({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||||
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 ? (
|
{hasPaket ? (
|
||||||
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
|
|
||||||
<SohbetClient
|
<SohbetClient
|
||||||
baslangicKredi={kredi}
|
baslangicKredi={kredi}
|
||||||
initialMesajlar={sohbetGecmisi}
|
initialMesajlar={sohbetGecmisi}
|
||||||
@@ -447,14 +386,11 @@ export function ListemGovde({
|
|||||||
bildikleri={danismaninBildikleri}
|
bildikleri={danismaninBildikleri}
|
||||||
genis={sohbetGenis}
|
genis={sohbetGenis}
|
||||||
onGenisDegistir={sohbetGenisligiAyarla}
|
onGenisDegistir={sohbetGenisligiAyarla}
|
||||||
revizyonHakki={kalanHak}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6 max-lg:contents">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Sohbet paket kartının üstünde: #sohbet çapası doğrudan
|
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
|
||||||
danışmana düşsün. */}
|
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
|
||||||
<div id="sohbet" className="scroll-mt-24 max-lg:order-first">
|
|
||||||
<SohbetClient
|
<SohbetClient
|
||||||
baslangicKredi={kredi}
|
baslangicKredi={kredi}
|
||||||
initialMesajlar={sohbetGecmisi}
|
initialMesajlar={sohbetGecmisi}
|
||||||
@@ -463,18 +399,14 @@ export function ListemGovde({
|
|||||||
uyarilar={uyarilar}
|
uyarilar={uyarilar}
|
||||||
ilkYorum={ilkYorum}
|
ilkYorum={ilkYorum}
|
||||||
bildikleri={danismaninBildikleri}
|
bildikleri={danismaninBildikleri}
|
||||||
// Paketsizde 480 px; kısa ekranda paketlideki boyla aynı tavana
|
className="h-[480px]"
|
||||||
// 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}
|
genis={sohbetGenis}
|
||||||
onGenisDegistir={sohbetGenisligiAyarla}
|
onGenisDegistir={sohbetGenisligiAyarla}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
ref={kilitKartRef}
|
ref={kilitKartRef}
|
||||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm max-lg:order-last"
|
className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="border-b border-slate-100 p-6">
|
<div className="border-b border-slate-100 p-6">
|
||||||
<h3 className="font-heading text-xl font-bold tracking-tight">
|
<h3 className="font-heading text-xl font-bold tracking-tight">
|
||||||
|
|||||||
100
src/features/rapor/components/revizyon-kutusu.tsx
Normal file
100
src/features/rapor/components/revizyon-kutusu.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
|
||||||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||||||
import { AracPilleri, aracPilleri } from "./arac-pilleri";
|
import { AracPilleri, aracPilleri } from "./arac-pilleri";
|
||||||
import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti";
|
import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti";
|
||||||
@@ -58,14 +57,6 @@ import { DotsLoader } from "@/components/ui/loader";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import type { ProfilAlani } from "@/lib/danisman-profil";
|
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ı. */
|
/** /api/soru/profil'in döndüğü, öğrenciye okunan profil satırı. */
|
||||||
export type ProfilSatiri = { alan: ProfilAlani; metin: string };
|
export type ProfilSatiri = { alan: ProfilAlani; metin: string };
|
||||||
@@ -74,72 +65,11 @@ export interface Mesaj {
|
|||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
/** Yapısal ek: soru formu / form cevabı / revizyon sonucu (lib/ai/sohbet-meta.ts) */
|
|
||||||
meta?: SohbetMeta | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kayıttan gelen araç parçalarının kimlik öneki: canlı akışın parçalarından ayırt edilir. */
|
/** Kayıtlı (düz metin) mesajı useChat'in parçalı mesajına çevirir. */
|
||||||
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 {
|
function uiMesaj(m: Mesaj): UIMessage {
|
||||||
const parts: UIMessage["parts"] = [{ type: "text", text: m.content }];
|
return { id: m.id, role: m.role, 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. */
|
/** Mesajın metni: araç turları arasındaki metin parçaları boş satırla birleşir. */
|
||||||
@@ -180,20 +110,6 @@ export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.
|
|||||||
*/
|
*/
|
||||||
const USTE_UZAKLIK_REM = 7;
|
const USTE_UZAKLIK_REM = 7;
|
||||||
const ALT_BLUR_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;
|
const GENIS_EN_AZ_PX = 380;
|
||||||
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
|
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
|
||||||
const GENIS_YUKSEKLIK = "";
|
const GENIS_YUKSEKLIK = "";
|
||||||
@@ -211,11 +127,10 @@ function sohbetKanaliKur(onBasladi: (krediDustu: boolean) => void) {
|
|||||||
let red: Red | null = null;
|
let red: Red | null = null;
|
||||||
const transport = new DefaultChatTransport<UIMessage>({
|
const transport = new DefaultChatTransport<UIMessage>({
|
||||||
api: "/api/soru",
|
api: "/api/soru",
|
||||||
// `body`: sendMessage'a verilen ek gövde (soru formunun yapısal cevabı)
|
prepareSendMessagesRequest: ({ messages }) => {
|
||||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
|
||||||
const son = messages[messages.length - 1];
|
const son = messages[messages.length - 1];
|
||||||
return {
|
return {
|
||||||
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id, ...(body ?? {}) },
|
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
fetch: async (girdiUrl, init) => {
|
fetch: async (girdiUrl, init) => {
|
||||||
@@ -329,8 +244,6 @@ const ORNEK_SORULAR = [
|
|||||||
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
|
"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?",
|
"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:
|
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
|
||||||
@@ -349,7 +262,6 @@ export function SohbetClient({
|
|||||||
genis: genisProp,
|
genis: genisProp,
|
||||||
onGenisDegistir,
|
onGenisDegistir,
|
||||||
genisYukseklik = GENIS_YUKSEKLIK,
|
genisYukseklik = GENIS_YUKSEKLIK,
|
||||||
revizyonHakki = 0,
|
|
||||||
}: {
|
}: {
|
||||||
baslangicKredi: number;
|
baslangicKredi: number;
|
||||||
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
|
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
|
||||||
@@ -381,8 +293,6 @@ export function SohbetClient({
|
|||||||
/** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır
|
/** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır
|
||||||
* (üst 7rem sticky hizası, alt 7rem progressive blur payı). */
|
* (üst 7rem sticky hizası, alt 7rem progressive blur payı). */
|
||||||
genisYukseklik?: string;
|
genisYukseklik?: string;
|
||||||
/** Kalan liste revizyonu hakkı (paketli); 0 ise revizyon örneği gösterilmez. */
|
|
||||||
revizyonHakki?: number;
|
|
||||||
}) {
|
}) {
|
||||||
const [girdi, setGirdi] = useState("");
|
const [girdi, setGirdi] = useState("");
|
||||||
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
|
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
|
||||||
@@ -495,41 +405,6 @@ export function SohbetClient({
|
|||||||
? [...mesajlar, BEKLENEN_CEVAP]
|
? [...mesajlar, BEKLENEN_CEVAP]
|
||||||
: mesajlar;
|
: 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
|
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
|
||||||
* ve giriş kutusuna koy. Akış yarıda koptuysa tahmin etme: sunucu krediyi
|
* ve giriş kutusuna koy. Akış yarıda koptuysa tahmin etme: sunucu krediyi
|
||||||
@@ -660,30 +535,6 @@ export function SohbetClient({
|
|||||||
void sendMessage({ id, role: "user", parts: [{ type: "text", text: mesaj }] });
|
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
|
* 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
|
* edilmez; danışman bir sonraki soruda sıfır geçmişle başlar (rapor özeti
|
||||||
@@ -717,7 +568,10 @@ export function SohbetClient({
|
|||||||
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
|
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
|
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
|
||||||
className ?? SOHBET_DAR_BOY,
|
// 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]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
|
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
|
||||||
@@ -789,7 +643,7 @@ export function SohbetClient({
|
|||||||
title="Kredilerin — yüklemek için tıkla"
|
title="Kredilerin — yüklemek için tıkla"
|
||||||
>
|
>
|
||||||
<Coins className="size-3.5" aria-hidden />
|
<Coins className="size-3.5" aria-hidden />
|
||||||
<SayiRulo deger={kredi} /> kredi
|
{kredi} kredi
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -941,7 +795,7 @@ export function SohbetClient({
|
|||||||
!ozet && "justify-center",
|
!ozet && "justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{[...(revizyonHakki > 0 ? [REVIZYON_ORNEGI] : []), ...ORNEK_SORULAR].map((s) => (
|
{ORNEK_SORULAR.map((s) => (
|
||||||
<PromptSuggestion
|
<PromptSuggestion
|
||||||
key={s}
|
key={s}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -987,8 +841,6 @@ export function SohbetClient({
|
|||||||
// Danışman veriye bakarken ne yaptığını hap olarak söyler
|
// Danışman veriye bakarken ne yaptığını hap olarak söyler
|
||||||
// (arac-pilleri.tsx); haplar cevap bitince de kalır.
|
// (arac-pilleri.tsx); haplar cevap bitince de kalır.
|
||||||
const piller = aracPilleri(m);
|
const piller = aracPilleri(m);
|
||||||
// Soru formu balonun altında (sohbet-anketi.tsx)
|
|
||||||
const anket = mesajinAnketi(m);
|
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={cevapAnahtari(gorunenMesajlar, i)}
|
key={cevapAnahtari(gorunenMesajlar, i)}
|
||||||
@@ -1015,13 +867,6 @@ export function SohbetClient({
|
|||||||
) : (
|
) : (
|
||||||
<CevapIskeleti />
|
<CevapIskeleti />
|
||||||
)}
|
)}
|
||||||
{anket ? (
|
|
||||||
<SohbetAnketi
|
|
||||||
anket={anket}
|
|
||||||
durum={anketDurumu(i)}
|
|
||||||
onGonder={(cevaplar) => anketiGonder(anket, cevaplar)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</Message>
|
</Message>
|
||||||
);
|
);
|
||||||
@@ -1071,11 +916,7 @@ export function SohbetClient({
|
|||||||
className="rounded-2xl border-slate-200"
|
className="rounded-2xl border-slate-200"
|
||||||
>
|
>
|
||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
placeholder={
|
placeholder="Sorunu yaz… (1 kredi)"
|
||||||
revizyonHakki > 0
|
|
||||||
? "Sorunu yaz ya da listende ne değişsin söyle… (1 kredi)"
|
|
||||||
: "Sorunu yaz… (1 kredi)"
|
|
||||||
}
|
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
/>
|
/>
|
||||||
<PromptInputActions className="justify-end pt-1">
|
<PromptInputActions className="justify-end pt-1">
|
||||||
|
|||||||
@@ -19,79 +19,6 @@ export type Senaryo = {
|
|||||||
const ARAC_BEKLEME = 1400;
|
const ARAC_BEKLEME = 1400;
|
||||||
|
|
||||||
export const SENARYOLAR: Senaryo[] = [
|
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ç",
|
ad: "Tek araç",
|
||||||
aciklama: "Bir arama, sonra kısa cevap.",
|
aciklama: "Bir arama, sonra kısa cevap.",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react";
|
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
uretimKilidiAl,
|
uretimKilidiAl,
|
||||||
uretimKilidiBirak,
|
uretimKilidiBirak,
|
||||||
@@ -15,8 +15,13 @@ import {
|
|||||||
grantCredits,
|
grantCredits,
|
||||||
krediBittiIsaretle,
|
krediBittiIsaretle,
|
||||||
RAPOR_KREDI,
|
RAPOR_KREDI,
|
||||||
|
MAX_REVIZYON,
|
||||||
} from "@/lib/credits";
|
} from "@/lib/credits";
|
||||||
import { raporUret, RaporUretimHatasi } from "@/lib/ai/rapor";
|
import {
|
||||||
|
raporUret,
|
||||||
|
listeOzetiCikar,
|
||||||
|
RaporUretimHatasi,
|
||||||
|
} from "@/lib/ai/rapor";
|
||||||
import type {
|
import type {
|
||||||
RaporParams,
|
RaporParams,
|
||||||
RaporSonuc,
|
RaporSonuc,
|
||||||
@@ -26,8 +31,6 @@ import { raporMaskele } from "@/lib/rapor-maske";
|
|||||||
|
|
||||||
|
|
||||||
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
|
// 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 =
|
export type ListeSonuc =
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
@@ -256,3 +259,133 @@ async function listeOlusturKilitli(
|
|||||||
hasPaket: user.hasPaket,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export async function sohbetGecmisiGetir(userId: string) {
|
|||||||
id: schema.chatMessages.id,
|
id: schema.chatMessages.id,
|
||||||
role: schema.chatMessages.role,
|
role: schema.chatMessages.role,
|
||||||
content: schema.chatMessages.content,
|
content: schema.chatMessages.content,
|
||||||
meta: schema.chatMessages.meta,
|
|
||||||
})
|
})
|
||||||
.from(schema.chatMessages)
|
.from(schema.chatMessages)
|
||||||
.where(eq(schema.chatMessages.userId, userId))
|
.where(eq(schema.chatMessages.userId, userId))
|
||||||
|
|||||||
@@ -81,48 +81,4 @@ export interface RaporParams {
|
|||||||
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
|
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
|
||||||
il?: string;
|
il?: string;
|
||||||
notlar?: 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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ import { useRouter } from "next/navigation";
|
|||||||
import { ArrowRight, Trophy, X } from "lucide-react";
|
import { ArrowRight, Trophy, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { SecimHaplari } from "@/components/ui/secim-haplari";
|
|
||||||
import {
|
import {
|
||||||
AltSayfa,
|
Drawer,
|
||||||
AltSayfaKapat,
|
DrawerClose,
|
||||||
AltSayfaGovde,
|
DrawerContent,
|
||||||
AltSayfaIcerik,
|
DrawerDescription,
|
||||||
AltSayfaAciklama,
|
DrawerTitle,
|
||||||
AltSayfaBaslik,
|
} from "@/components/ui/drawer";
|
||||||
} from "@/components/ui/alt-sayfa";
|
|
||||||
import { PikselZemin } from "@/components/piksel-zemin";
|
import { PikselZemin } from "@/components/piksel-zemin";
|
||||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +37,6 @@ const PUAN_TURLERI_SECENEK = [
|
|||||||
{ deger: "dil", etiket: "Dil" },
|
{ deger: "dil", etiket: "Dil" },
|
||||||
{ deger: "tyt", etiket: "TYT" },
|
{ deger: "tyt", etiket: "TYT" },
|
||||||
] as const;
|
] as const;
|
||||||
type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"];
|
|
||||||
|
|
||||||
// Kayıtlı profilli CTA'lar /listem boş durumunun dilini taşır (bkz.
|
// Kayıtlı profilli CTA'lar /listem boş durumunun dilini taşır (bkz.
|
||||||
// listem-bos-cta.tsx): büyük turuncu buton. Sınıf seti tek yerde.
|
// listem-bos-cta.tsx): büyük turuncu buton. Sınıf seti tek yerde.
|
||||||
@@ -141,7 +138,7 @@ export function CtaSiraForm({
|
|||||||
const [acik, setAcik] = useState(false);
|
const [acik, setAcik] = useState(false);
|
||||||
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
|
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
|
||||||
const [siralama, setSiralama] = useState("");
|
const [siralama, setSiralama] = useState("");
|
||||||
const [tur, setTur] = useState<PuanTuru>("say");
|
const [tur, setTur] = useState<string>("say");
|
||||||
const [sira, setSira] = useState<number | null>(null);
|
const [sira, setSira] = useState<number | null>(null);
|
||||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||||||
const [yukleniyor, setYukleniyor] = useState(false);
|
const [yukleniyor, setYukleniyor] = useState(false);
|
||||||
@@ -239,14 +236,28 @@ export function CtaSiraForm({
|
|||||||
: "flex flex-col gap-3"
|
: "flex flex-col gap-3"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */}
|
<div
|
||||||
<SecimHaplari
|
role="radiogroup"
|
||||||
ariaLabel="Puan türü"
|
aria-label="Puan türü"
|
||||||
secenekler={PUAN_TURLERI_SECENEK}
|
className={cn("flex flex-wrap gap-1.5", kompakt && "lg:shrink-0")}
|
||||||
deger={tur}
|
>
|
||||||
onChange={setTur}
|
{PUAN_TURLERI_SECENEK.map((t) => (
|
||||||
className={cn(kompakt && "lg:shrink-0")}
|
<button
|
||||||
/>
|
key={t.deger}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={tur === t.deger}
|
||||||
|
onClick={() => setTur(t.deger)}
|
||||||
|
className={
|
||||||
|
tur === t.deger
|
||||||
|
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
|
||||||
|
: "cursor-pointer rounded-full border border-slate-200 bg-card px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.etiket}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label htmlFor={alanId} className="sr-only">
|
<label htmlFor={alanId} className="sr-only">
|
||||||
YKS başarı sıralaması
|
YKS başarı sıralaması
|
||||||
@@ -306,15 +317,19 @@ export function CtaSiraForm({
|
|||||||
// başlığı yalnız ekran okuyucuya.
|
// başlığı yalnız ekran okuyucuya.
|
||||||
const siraAdimi = !kompakt && adim === "sira";
|
const siraAdimi = !kompakt && adim === "sira";
|
||||||
const cekmece = (
|
const cekmece = (
|
||||||
<AltSayfa open={acik} onOpenChange={acikDegisti}>
|
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
|
||||||
<AltSayfaIcerik>
|
<DrawerContent
|
||||||
<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]">
|
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]">
|
||||||
<X className="size-4" aria-hidden />
|
<X className="size-4" aria-hidden />
|
||||||
<span className="sr-only">Kapat</span>
|
<span className="sr-only">Kapat</span>
|
||||||
</AltSayfaKapat>
|
</DrawerClose>
|
||||||
|
|
||||||
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
<div
|
||||||
<AltSayfaBaslik
|
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={
|
className={
|
||||||
siraAdimi
|
siraAdimi
|
||||||
? "px-10 text-center font-heading text-2xl font-bold text-slate-900"
|
? "px-10 text-center font-heading text-2xl font-bold text-slate-900"
|
||||||
@@ -322,8 +337,8 @@ export function CtaSiraForm({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
Sıralamana yakın programlar hangileri?
|
Sıralamana yakın programlar hangileri?
|
||||||
</AltSayfaBaslik>
|
</DrawerTitle>
|
||||||
<AltSayfaAciklama
|
<DrawerDescription
|
||||||
className={
|
className={
|
||||||
siraAdimi
|
siraAdimi
|
||||||
? "mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500"
|
? "mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500"
|
||||||
@@ -333,7 +348,7 @@ export function CtaSiraForm({
|
|||||||
Puan türünü seç, sıralamanı yaz; hayal, dengeli ve güvenli
|
Puan türünü seç, sıralamanı yaz; hayal, dengeli ve güvenli
|
||||||
dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan
|
dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan
|
||||||
ayrılmadan gör. Ücretsiz, üyelik gerekmez.
|
ayrılmadan gör. Ücretsiz, üyelik gerekmez.
|
||||||
</AltSayfaAciklama>
|
</DrawerDescription>
|
||||||
{siraAdimi ? (
|
{siraAdimi ? (
|
||||||
<div className="mt-5">{siraFormu}</div>
|
<div className="mt-5">{siraFormu}</div>
|
||||||
) : facetler && sira != null ? (
|
) : facetler && sira != null ? (
|
||||||
@@ -348,9 +363,9 @@ export function CtaSiraForm({
|
|||||||
onTamamla={secimleriTamamla}
|
onTamamla={secimleriTamamla}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</AltSayfaGovde>
|
</div>
|
||||||
</AltSayfaIcerik>
|
</DrawerContent>
|
||||||
</AltSayfa>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (kompakt) {
|
if (kompakt) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
|
|||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { SecimHaplari } from "@/components/ui/secim-haplari";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -20,9 +19,12 @@ import {
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { olay, siraKovasi } from "@/lib/analitik";
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
import {
|
||||||
|
profilDuzenlemeyiAc,
|
||||||
|
useTercihProfili,
|
||||||
|
} from "@/features/liste/hooks/use-tercih-profili";
|
||||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||||
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
|
|
||||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||||
@@ -33,11 +35,10 @@ const PUAN_TURLERI_SECENEK = [
|
|||||||
{ deger: "dil", etiket: "Dil" },
|
{ deger: "dil", etiket: "Dil" },
|
||||||
{ deger: "tyt", etiket: "TYT" },
|
{ deger: "tyt", etiket: "TYT" },
|
||||||
] as const;
|
] as const;
|
||||||
type PuanTuru = (typeof PUAN_TURLERI_SECENEK)[number]["deger"];
|
|
||||||
|
|
||||||
export function HeroForm() {
|
export function HeroForm() {
|
||||||
const [siralama, setSiralama] = useState("");
|
const [siralama, setSiralama] = useState("");
|
||||||
const [tur, setTur] = useState<PuanTuru>("say");
|
const [tur, setTur] = useState<string>("say");
|
||||||
const [sira, setSira] = useState<number | null>(null);
|
const [sira, setSira] = useState<number | null>(null);
|
||||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||||||
const [yukleniyor, setYukleniyor] = useState(false);
|
const [yukleniyor, setYukleniyor] = useState(false);
|
||||||
@@ -104,10 +105,11 @@ export function HeroForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||||||
// Sıralamayı değiştirmek navbar'daki sıralama hapından yapılır.
|
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
|
||||||
|
// (TercihProfiliKapisi, layout'ta monte) açar.
|
||||||
if (kayitliProfil) {
|
if (kayitliProfil) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md flex-col items-center">
|
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -118,6 +120,18 @@ export function HeroForm() {
|
|||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,14 +140,28 @@ export function HeroForm() {
|
|||||||
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
|
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
|
||||||
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
|
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
|
||||||
<div className="flex w-full max-w-xl flex-col items-center gap-3">
|
<div className="flex w-full max-w-xl flex-col items-center gap-3">
|
||||||
{/* Seçili vurgu çipten çipe kayar (Arc kayan vurgu, secim-haplari.tsx). */}
|
<div
|
||||||
<SecimHaplari
|
role="radiogroup"
|
||||||
ariaLabel="Puan türü"
|
aria-label="Puan türü"
|
||||||
secenekler={PUAN_TURLERI_SECENEK}
|
className="flex flex-wrap justify-center gap-1.5"
|
||||||
deger={tur}
|
>
|
||||||
onChange={setTur}
|
{PUAN_TURLERI_SECENEK.map((t) => (
|
||||||
className="justify-center"
|
<button
|
||||||
/>
|
key={t.deger}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={tur === t.deger}
|
||||||
|
onClick={() => setTur(t.deger)}
|
||||||
|
className={
|
||||||
|
tur === t.deger
|
||||||
|
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
|
||||||
|
: "cursor-pointer rounded-full border border-slate-200 bg-card px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.etiket}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
|
||||||
import { useReducedMotion } from "motion/react";
|
import { useReducedMotion } from "motion/react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { toast } from "@/components/ui/bildirim";
|
import { toast } from "sonner";
|
||||||
import { cities } from "turkey-map-react/lib/data";
|
import { cities } from "turkey-map-react/lib/data";
|
||||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,8 +23,47 @@ import { trBaslikDuzeni } from "@/lib/slug";
|
|||||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||||
import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler";
|
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";
|
import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum";
|
||||||
// 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";
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
|
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
|
||||||
|
|
||||||
@@ -298,7 +337,7 @@ export function SihirbazAdimlar({
|
|||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
|
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
|
||||||
</p>
|
</p>
|
||||||
<CipGrubu etiket="İlgi alanları" className="mt-4">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{facetler.kategoriler.map((k) => (
|
{facetler.kategoriler.map((k) => (
|
||||||
<Cip
|
<Cip
|
||||||
key={k.ad}
|
key={k.ad}
|
||||||
@@ -309,7 +348,7 @@ export function SihirbazAdimlar({
|
|||||||
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
|
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
|
||||||
</Cip>
|
</Cip>
|
||||||
))}
|
))}
|
||||||
</CipGrubu>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref={panelRef}>
|
<div ref={panelRef}>
|
||||||
<h3 className="font-heading text-xl font-bold text-slate-900">
|
<h3 className="font-heading text-xl font-bold text-slate-900">
|
||||||
@@ -348,9 +387,8 @@ export function SihirbazAdimlar({
|
|||||||
görünüm ilk 10 il; kalanı "+N il daha" rozetiyle açılır. Arama
|
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ı
|
yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
|
||||||
devre dışı bırakır. */}
|
devre dışı bırakır. */}
|
||||||
<CipGrubu
|
<div
|
||||||
etiket="Şehirler"
|
className={`mt-3 flex max-h-64 flex-wrap gap-2 overflow-y-auto sm:max-h-none sm:overflow-visible ${
|
||||||
className={`mt-3 max-h-64 overflow-y-auto sm:max-h-none sm:overflow-visible ${
|
|
||||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -386,7 +424,7 @@ export function SihirbazAdimlar({
|
|||||||
Daha az göster
|
Daha az göster
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</CipGrubu>
|
</div>
|
||||||
|
|
||||||
{ilAramaNorm &&
|
{ilAramaNorm &&
|
||||||
gorunenTurkiyeIlleri.length === 0 &&
|
gorunenTurkiyeIlleri.length === 0 &&
|
||||||
@@ -401,9 +439,8 @@ export function SihirbazAdimlar({
|
|||||||
<p className="mb-2 text-sm font-semibold text-slate-700">
|
<p className="mb-2 text-sm font-semibold text-slate-700">
|
||||||
KKTC & Yurt Dışı
|
KKTC & Yurt Dışı
|
||||||
</p>
|
</p>
|
||||||
<CipGrubu
|
<div
|
||||||
etiket="KKTC ve yurt dışı"
|
className={`flex flex-wrap gap-2 ${
|
||||||
className={`${
|
|
||||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -418,7 +455,7 @@ export function SihirbazAdimlar({
|
|||||||
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
|
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
|
||||||
</Cip>
|
</Cip>
|
||||||
))}
|
))}
|
||||||
</CipGrubu>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -463,9 +500,8 @@ export function SihirbazAdimlar({
|
|||||||
<p className="mb-2 text-sm font-semibold text-slate-700">
|
<p className="mb-2 text-sm font-semibold text-slate-700">
|
||||||
Üniversite tipi
|
Üniversite tipi
|
||||||
</p>
|
</p>
|
||||||
<CipGrubu
|
<div
|
||||||
etiket="Üniversite tipi"
|
className={`flex flex-wrap gap-2 ${
|
||||||
className={`${
|
|
||||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -489,7 +525,7 @@ export function SihirbazAdimlar({
|
|||||||
>
|
>
|
||||||
Fark etmez
|
Fark etmez
|
||||||
</Cip>
|
</Cip>
|
||||||
</CipGrubu>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-sm font-semibold text-slate-700">
|
<p className="mb-2 text-sm font-semibold text-slate-700">
|
||||||
@@ -497,7 +533,7 @@ export function SihirbazAdimlar({
|
|||||||
</p>
|
</p>
|
||||||
{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda
|
{/* 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. */}
|
yedi çip 44 px'le pencereyi 812 px'in dışına itiyordu. */}
|
||||||
<CipGrubu etiket="Senin için önemli olan" className="grid grid-cols-2 sm:flex sm:flex-wrap">
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||||
{ONCELIKLER.map((o) => (
|
{ONCELIKLER.map((o) => (
|
||||||
<Cip
|
<Cip
|
||||||
key={o}
|
key={o}
|
||||||
@@ -508,7 +544,7 @@ export function SihirbazAdimlar({
|
|||||||
{o}
|
{o}
|
||||||
</Cip>
|
</Cip>
|
||||||
))}
|
))}
|
||||||
</CipGrubu>
|
</div>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
Listeyi daraltmaz; programların sırasını ve gerekçelerini
|
Listeyi daraltmaz; programların sırasını ve gerekçelerini
|
||||||
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
|
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
|
||||||
@@ -523,7 +559,7 @@ export function SihirbazAdimlar({
|
|||||||
Özel kontenjan durumun{" "}
|
Özel kontenjan durumun{" "}
|
||||||
<span className="font-normal text-slate-500">(isteğe bağlı)</span>
|
<span className="font-normal text-slate-500">(isteğe bağlı)</span>
|
||||||
</p>
|
</p>
|
||||||
<CipGrubu etiket="Özel kontenjan durumun" className="grid grid-cols-2 sm:flex sm:flex-wrap">
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||||
{OZEL_DURUMLAR.map((d) => (
|
{OZEL_DURUMLAR.map((d) => (
|
||||||
<Cip
|
<Cip
|
||||||
key={d}
|
key={d}
|
||||||
@@ -541,7 +577,7 @@ export function SihirbazAdimlar({
|
|||||||
{OZEL_DURUM_ETIKET[d]}
|
{OZEL_DURUM_ETIKET[d]}
|
||||||
</Cip>
|
</Cip>
|
||||||
))}
|
))}
|
||||||
</CipGrubu>
|
</div>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
|
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
|
||||||
kontenjanı olan programlar listende öne çıkar ve danışman bunu
|
kontenjanı olan programlar listende öne çıkar ve danışman bunu
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
|
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
|
||||||
*
|
*
|
||||||
* Üç tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
|
* İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
|
||||||
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
|
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
|
||||||
* (src/lib/danisman-profil.ts); `soru_sor` + `liste_revize` sohbetten liste
|
* (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
|
||||||
* revizyonu (7 Eki 2026): danışman belirsiz isteği form olarak sorar
|
* üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
|
||||||
* (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).
|
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
|
||||||
*/
|
*/
|
||||||
@@ -89,19 +85,11 @@ import {
|
|||||||
} from "@/lib/danisman-profil";
|
} from "@/lib/danisman-profil";
|
||||||
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
|
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
|
||||||
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
|
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 = {
|
export type AracBaglami = {
|
||||||
userId: string;
|
userId: string;
|
||||||
sira: number;
|
sira: number;
|
||||||
tur: PuanTuruKey;
|
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 */
|
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
|
||||||
profil: DanismanProfili;
|
profil: DanismanProfili;
|
||||||
/** Sihirbaz öncelik çipleri (uyum puanı / öne çıkan için) */
|
/** Sihirbaz öncelik çipleri (uyum puanı / öne çıkan için) */
|
||||||
@@ -417,123 +405,6 @@ 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 ──────────────────────────────────────────────────────────
|
// ── Yardımcılar ──────────────────────────────────────────────────────────
|
||||||
@@ -1609,168 +1480,6 @@ function alanHatasi(ad: string, s: AlanSemasi, v: unknown): string | null {
|
|||||||
return 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,
|
* 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
|
* yanlış tip, geçersiz seçenek, alt sınır, eksik zorunlu alan. Hata varsa
|
||||||
@@ -1839,10 +1548,6 @@ export function aracCalistirici(b: AracBaglami) {
|
|||||||
return rehberOku(b, a);
|
return rehberOku(b, a);
|
||||||
case "profil_guncelle":
|
case "profil_guncelle":
|
||||||
return profilGuncelle(b, a);
|
return profilGuncelle(b, a);
|
||||||
case "soru_sor":
|
|
||||||
return soruSor(a);
|
|
||||||
case "liste_revize":
|
|
||||||
return listeRevize(b, a);
|
|
||||||
default:
|
default:
|
||||||
return { hata: `Bilinmeyen araç: ${ad}` };
|
return { hata: `Bilinmeyen araç: ${ad}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,25 +107,6 @@ export function kayitBeyaniVar(metin: string): boolean {
|
|||||||
export const KAYIT_DUZELTME_NOTU =
|
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.)";
|
"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
|
* İç ş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
|
* da JSON şema anahtarları geçiyor (5 Eki 2026 eval s077: 11 aracın adı ve
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import { PUAN_TURLERI } from "@/types/yokatlas";
|
|||||||
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
||||||
import { adayDurumu, ozelDurumOzeti } from "@/lib/ozel-durum";
|
import { adayDurumu, ozelDurumOzeti } from "@/lib/ozel-durum";
|
||||||
import { ONCEKI_YIL, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
|
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:
|
* Profil kaydı öncesi "kaydedeyim mi?" onayı (KVKK Üretken YZ Rehberi:
|
||||||
@@ -66,49 +64,6 @@ programda baraj yok ya da veride yok — baraj UYDURMA. Ön lisans (TYT)
|
|||||||
programlarında başarı sırası barajı yoktur.`;
|
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: {
|
export function sistemPromptu(opts: {
|
||||||
params: RaporParams;
|
params: RaporParams;
|
||||||
listeOzeti: string;
|
listeOzeti: string;
|
||||||
@@ -122,11 +77,6 @@ export function sistemPromptu(opts: {
|
|||||||
* değerlendirme verilmez (liste gizli); uyarılar herkese.
|
* değerlendirme verilmez (liste gizli); uyarılar herkese.
|
||||||
*/
|
*/
|
||||||
rapor?: { genelDegerlendirme?: string; uyarilar?: string[]; kapsam?: { toplam: number; uyarlandi: boolean } };
|
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 {
|
}): string {
|
||||||
const { params } = opts;
|
const { params } = opts;
|
||||||
const secimVar =
|
const secimVar =
|
||||||
@@ -223,9 +173,8 @@ KURALLAR:
|
|||||||
Kendi genel bilginle kural, oran, tutar, katsayı ya da tarih YAZMA. Yazı
|
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
|
bulunmazsa "rehberimizde bu konuda yazı yok" de ve ÖSYM kılavuzuna ya da
|
||||||
ilgili kuruma yönlendir.
|
ilgili kuruma yönlendir.
|
||||||
- ARAÇ KURALLARI: sorgu araçları yalnız okur; 24'lük listeyi kendin
|
- ARAÇ KURALLARI: araçlar yalnız okur; 24'lük listeyi araçla kurmazsın,
|
||||||
KURMAZSIN, satır ekleyip çıkarmazsın — listeyi değiştiren tek yol
|
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
|
||||||
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
|
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).
|
(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:")
|
Araç çağırmadan ÖNCE yazdığın metin ("İşte en iyi 10 üniversite:")
|
||||||
@@ -322,7 +271,6 @@ KURALLAR:
|
|||||||
// TYT sırası lisans barajlarıyla kıyaslanmaz; ön lisansta baraj yok
|
// TYT sırası lisans barajlarıyla kıyaslanmaz; ön lisansta baraj yok
|
||||||
const baraj = params.tur === "tyt" ? null : barajBolumu(params.sira, opts.barajlar);
|
const baraj = params.tur === "tyt" ? null : barajBolumu(params.sira, opts.barajlar);
|
||||||
if (baraj) parcalar.splice(2, 0, baraj);
|
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
|
// Raporun üst metni: adayın listesini kuran yapay zekânın genel
|
||||||
// değerlendirmesi ve uyarıları — danışman "listem nasıl kuruldu?" sorusunu
|
// değerlendirmesi ve uyarıları — danışman "listem nasıl kuruldu?" sorusunu
|
||||||
// kendi raporundan cevaplar. Kilitli kullanıcıda genel değerlendirme gizli.
|
// kendi raporundan cevaplar. Kilitli kullanıcıda genel değerlendirme gizli.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
havuzuKompaktJson,
|
havuzuKompaktJson,
|
||||||
turLabel,
|
turLabel,
|
||||||
dilimHedefiHesapla,
|
dilimHedefiHesapla,
|
||||||
|
idealHedef,
|
||||||
IDEAL_HEDEF,
|
IDEAL_HEDEF,
|
||||||
type DilimHedef,
|
type DilimHedef,
|
||||||
type Dilim,
|
type Dilim,
|
||||||
@@ -39,14 +40,6 @@ import {
|
|||||||
} from "../risk";
|
} from "../risk";
|
||||||
import { trendOzetiMetni } from "../trend";
|
import { trendOzetiMetni } from "../trend";
|
||||||
import { ILK_YIL } from "../veri-yillari";
|
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
|
// 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
|
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
|
||||||
@@ -371,7 +364,7 @@ function mockRapor(
|
|||||||
revizyonNotu?: string,
|
revizyonNotu?: string,
|
||||||
): RaporSonuc {
|
): RaporSonuc {
|
||||||
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
|
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
|
||||||
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
|
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
|
||||||
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
|
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
|
||||||
const efektif = (p: AdayProgram) => p.efektifSira ?? 0;
|
const efektif = (p: AdayProgram) => p.efektifSira ?? 0;
|
||||||
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
|
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
|
||||||
@@ -414,17 +407,13 @@ function mockRapor(
|
|||||||
"(Dev mock) Güvenli dilimindeki son tercihleri asla silme.",
|
"(Dev mock) Güvenli dilimindeki son tercihleri asla silme.",
|
||||||
],
|
],
|
||||||
programlar,
|
programlar,
|
||||||
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
|
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
|
||||||
devMock: true,
|
devMock: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Modele giden havuz: dilim başına üç dizi; satırda `dilim` alanı yok (dizinin adı dilimdir).
|
/** 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[]): string {
|
||||||
function havuzuDilimliJson(
|
|
||||||
havuz: AdayProgram[],
|
|
||||||
oncelikli?: ((p: AdayProgram) => boolean) | null,
|
|
||||||
): string {
|
|
||||||
const satirlar = JSON.parse(havuzuKompaktJson(havuz)) as ({
|
const satirlar = JSON.parse(havuzuKompaktJson(havuz)) as ({
|
||||||
id: string;
|
id: string;
|
||||||
dilim: Dilim;
|
dilim: Dilim;
|
||||||
@@ -434,11 +423,7 @@ function havuzuDilimliJson(
|
|||||||
dengeli: [],
|
dengeli: [],
|
||||||
guvenli: [],
|
guvenli: [],
|
||||||
};
|
};
|
||||||
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
|
for (const { dilim, ...kalan } of satirlar) gruplar[TEL_ADI[dilim]].push(kalan);
|
||||||
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);
|
return JSON.stringify(gruplar);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,17 +482,7 @@ export async function raporUretAyrintili(
|
|||||||
oncekiListeOzeti?: string,
|
oncekiListeOzeti?: string,
|
||||||
profil?: DanismanProfili,
|
profil?: DanismanProfili,
|
||||||
): Promise<{ rapor: RaporSonuc; tani: RaporTani | null }> {
|
): Promise<{ rapor: RaporSonuc; tani: RaporTani | null }> {
|
||||||
// Sohbet revizyonu (revizyon-kurali.ts): il/alan/tip farkı sihirbaz seçiminin
|
const { havuz, gevsetildi } = havuzOlustur(params, profil);
|
||||||
// ü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) : [];
|
const profilSatir = profil ? profilSatirlari(profil) : [];
|
||||||
if (havuz.length < MIN_LISTE) {
|
if (havuz.length < MIN_LISTE) {
|
||||||
throw new RaporUretimHatasi(
|
throw new RaporUretimHatasi(
|
||||||
@@ -519,17 +494,15 @@ export async function raporUretAyrintili(
|
|||||||
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
|
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
|
||||||
// üretim her seferinde doğrulamada patlıyordu. İskelet "Kolay yerleşme"
|
// üretim her seferinde doğrulamada patlıyordu. İskelet "Kolay yerleşme"
|
||||||
// seçiliyse 4/12/8 (idealHedef).
|
// seçiliyse 4/12/8 (idealHedef).
|
||||||
const hedef = dilimHedefiHesapla(havuz, revizyonIskeleti(params));
|
const hedef = dilimHedefiHesapla(havuz, idealHedef(params.oncelikler));
|
||||||
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
|
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
|
||||||
|
|
||||||
const secimVar =
|
const secimVar =
|
||||||
(havuzParams.kategoriler?.length ?? 0) > 0 ||
|
(params.kategoriler?.length ?? 0) > 0 ||
|
||||||
(havuzParams.iller?.length ?? 0) > 0 ||
|
(params.iller?.length ?? 0) > 0 ||
|
||||||
(havuzParams.oncelikler?.length ?? 0) > 0 ||
|
(params.oncelikler?.length ?? 0) > 0 ||
|
||||||
(havuzParams.ozelDurum?.length ?? 0) > 0 ||
|
(params.ozelDurum?.length ?? 0) > 0 ||
|
||||||
(havuzParams.universiteTipi && havuzParams.universiteTipi !== "farketmez");
|
(params.universiteTipi && params.universiteTipi !== "farketmez");
|
||||||
const kuralSatirlari = kuralOzeti(kural);
|
|
||||||
const ilkN = kural?.ilkTercihler;
|
|
||||||
|
|
||||||
const gevsetmeNotu: string[] = [];
|
const gevsetmeNotu: string[] = [];
|
||||||
if (gevsetildi.includes("profil")) {
|
if (gevsetildi.includes("profil")) {
|
||||||
@@ -557,20 +530,13 @@ export async function raporUretAyrintili(
|
|||||||
const kullaniciMesaji = [
|
const kullaniciMesaji = [
|
||||||
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
|
`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({
|
secimVar ? `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
|
||||||
kategoriler: havuzParams.kategoriler ?? [],
|
kategoriler: params.kategoriler ?? [],
|
||||||
iller: havuzParams.iller ?? [],
|
iller: params.iller ?? [],
|
||||||
universiteTipi: havuzParams.universiteTipi ?? "farketmez",
|
universiteTipi: params.universiteTipi ?? "farketmez",
|
||||||
oncelikler: havuzParams.oncelikler ?? [],
|
oncelikler: params.oncelikler ?? [],
|
||||||
ozelDurum: havuzParams.ozelDurum ?? [],
|
ozelDurum: params.ozelDurum ?? [],
|
||||||
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
|
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
|
||||||
: null,
|
: 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
|
params.notlar
|
||||||
? `Adayın ek notları: <aday_notu>${kullaniciMetni(params.notlar)}</aday_notu>`
|
? `Adayın ek notları: <aday_notu>${kullaniciMetni(params.notlar)}</aday_notu>`
|
||||||
: null,
|
: null,
|
||||||
@@ -581,7 +547,7 @@ export async function raporUretAyrintili(
|
|||||||
revizyonNotu
|
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"}`
|
? `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,
|
: null,
|
||||||
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz, oncelikli)}`,
|
`ADAY HAVUZU (yalnızca buradan seç; dizi adı = liste adı): ${havuzuDilimliJson(havuz)}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
@@ -592,12 +558,11 @@ export async function raporUretAyrintili(
|
|||||||
return p.efektifSira ?? Number.MAX_SAFE_INTEGER;
|
return p.efektifSira ?? Number.MAX_SAFE_INTEGER;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Adayın seçimine uyum (tamamlama önceliği ve şablon gerekçe için);
|
// 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 = params.kategoriler ?? [];
|
||||||
const seciliKategoriler = havuzParams.kategoriler ?? [];
|
|
||||||
const seciliIller = [
|
const seciliIller = [
|
||||||
...(havuzParams.il ? [havuzParams.il] : []),
|
...(params.il ? [params.il] : []),
|
||||||
...(havuzParams.iller ?? []),
|
...(params.iller ?? []),
|
||||||
];
|
];
|
||||||
const alanUyar = (p: AdayProgram) =>
|
const alanUyar = (p: AdayProgram) =>
|
||||||
seciliKategoriler.length > 0 &&
|
seciliKategoriler.length > 0 &&
|
||||||
@@ -606,10 +571,7 @@ export async function raporUretAyrintili(
|
|||||||
seciliIller.length > 0 && p.il != null && seciliIller.includes(p.il);
|
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
|
// Alan 2, il 1, uyum puanı 0-1: tamamlama ve kırpma önce seçime uyana, eşitte
|
||||||
// uyum puanı yüksek olana gider
|
// uyum puanı yüksek olana gider
|
||||||
// Revizyonun "ilk N" ölçütüne uyan program her şeyin önünde (+4): kırpma ve
|
const uyum = (p: AdayProgram) => (alanUyar(p) ? 2 : 0) + (ilUyar(p) ? 1 : 0) + (p.uyum ?? 0.5);
|
||||||
// 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ı
|
// 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.
|
// yapılmaz; altındaysa liste çoğunlukla kodla dolardı, model bir kez daha denenir.
|
||||||
@@ -846,7 +808,7 @@ export async function raporUretAyrintili(
|
|||||||
// Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
|
// 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
|
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
|
||||||
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
|
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
|
||||||
const dilimSirali = DILIMLER.flatMap((dilim) =>
|
const tercihler = DILIMLER.flatMap((dilim) =>
|
||||||
[...nihai.secim[dilim]]
|
[...nihai.secim[dilim]]
|
||||||
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
|
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
|
||||||
.map((t) => ({ dilim, satir: t })),
|
.map((t) => ({ dilim, satir: t })),
|
||||||
@@ -862,15 +824,13 @@ export async function raporUretAyrintili(
|
|||||||
...(p.oneCikan?.length ? { oneCikan: p.oneCikan } : {}),
|
...(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 {
|
return {
|
||||||
rapor: {
|
rapor: {
|
||||||
tercihler,
|
tercihler,
|
||||||
genelDegerlendirme: genel,
|
genelDegerlendirme: genel,
|
||||||
uyarilar,
|
uyarilar,
|
||||||
programlar,
|
programlar,
|
||||||
kapsam: kapsamUret(hedef, revizyonIskeleti(params)),
|
kapsam: kapsamUret(hedef, idealHedef(params.oncelikler)),
|
||||||
},
|
},
|
||||||
tani: {
|
tani: {
|
||||||
kaynak,
|
kaynak,
|
||||||
|
|||||||
@@ -24,14 +24,7 @@ import {
|
|||||||
} from "ai";
|
} from "ai";
|
||||||
import { ARACLAR, aracSeti, type AracBaglami } from "./araclar";
|
import { ARACLAR, aracSeti, type AracBaglami } from "./araclar";
|
||||||
import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
|
import { kaliciDislananSaglayicilar, sohbetDilModeli } from "./client";
|
||||||
import {
|
import { KAYIT_DUZELTME_NOTU, kayitBeyaniVar, semaSizintisiVar, vaatDiliBul } from "./cikti-denetimi";
|
||||||
KAYIT_DUZELTME_NOTU,
|
|
||||||
REVIZE_DUZELTME_NOTU,
|
|
||||||
kayitBeyaniVar,
|
|
||||||
revizeBeyaniVar,
|
|
||||||
semaSizintisiVar,
|
|
||||||
vaatDiliBul,
|
|
||||||
} from "./cikti-denetimi";
|
|
||||||
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
|
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
|
||||||
|
|
||||||
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
|
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
|
||||||
@@ -47,12 +40,6 @@ const ADIM_BASINA_ARAC = 4;
|
|||||||
const CEVAP_BASINA_ARAC = 8;
|
const CEVAP_BASINA_ARAC = 8;
|
||||||
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
|
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
|
||||||
const ARAC_ADLARI = ARACLAR.flatMap((a) => (a.type === "function" ? [a.function.name] : []));
|
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 = {
|
export type CiktiDenetimi = {
|
||||||
/** Jev'in ihlal olasılığı; denetim yapılamadıysa null. */
|
/** Jev'in ihlal olasılığı; denetim yapılamadıysa null. */
|
||||||
@@ -65,8 +52,6 @@ export type CiktiDenetimi = {
|
|||||||
reddedilenArac: number;
|
reddedilenArac: number;
|
||||||
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
|
/** "Kaydettim" dendi ama başarılı profil_guncelle yoktu; düzeltme notu eklendi. */
|
||||||
kayitNotu: boolean;
|
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). */
|
/** Cevapta araç adları / şema anahtarları var (yalnız gözlem). */
|
||||||
semaSizintisi: boolean;
|
semaSizintisi: boolean;
|
||||||
};
|
};
|
||||||
@@ -83,11 +68,6 @@ export function danismanAkisi(opts: {
|
|||||||
zamanAsimiMs?: number;
|
zamanAsimiMs?: number;
|
||||||
/** Çıktı denetiminin sonucu (gözlem/eval için; cevabı etkilemez). */
|
/** Çıktı denetiminin sonucu (gözlem/eval için; cevabı etkilemez). */
|
||||||
onDenetim?: (d: CiktiDenetimi) => void;
|
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> {
|
}): ReadableStream<UIMessageChunk> {
|
||||||
return createUIMessageStream<UIMessage>({
|
return createUIMessageStream<UIMessage>({
|
||||||
execute: async ({ writer }) => {
|
execute: async ({ writer }) => {
|
||||||
@@ -96,19 +76,12 @@ export function danismanAkisi(opts: {
|
|||||||
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
|
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
|
||||||
let akisHatasi: string | null = null;
|
let akisHatasi: string | null = null;
|
||||||
// Sağlayıcı takılırsa SDK akışı "abort" parçasıyla sessizce bitirir
|
// 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. Süre
|
// (hata fırlatmaz); döngüden sonra sinyale ayrıca bakılır.
|
||||||
// uzatılabilir olsun diye AbortSignal.timeout yerine zamanlayıcı.
|
const zamanAsimi = AbortSignal.timeout(opts.zamanAsimiMs ?? 120_000);
|
||||||
const kesici = new AbortController();
|
|
||||||
let sure = opts.zamanAsimiMs ?? 120_000;
|
|
||||||
let zamanlayici = setTimeout(() => kesici.abort(), sure);
|
|
||||||
const zamanAsimi = kesici.signal;
|
|
||||||
let adimdakiArac = 0;
|
let adimdakiArac = 0;
|
||||||
let toplamArac = 0;
|
let toplamArac = 0;
|
||||||
let reddedilenArac = 0;
|
let reddedilenArac = 0;
|
||||||
let basariliKayit = 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 {
|
try {
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: sohbetDilModeli(),
|
model: sohbetDilModeli(),
|
||||||
@@ -116,36 +89,19 @@ export function danismanAkisi(opts: {
|
|||||||
messages: opts.mesajlar,
|
messages: opts.mesajlar,
|
||||||
tools: aracSeti(
|
tools: aracSeti(
|
||||||
opts.aracBaglami,
|
opts.aracBaglami,
|
||||||
(ad) => {
|
(ad) => console.info("[soru] araç:", ad),
|
||||||
console.info("[soru] araç:", ad);
|
() => {
|
||||||
if (ad === "liste_revize") {
|
|
||||||
clearTimeout(zamanlayici);
|
|
||||||
sure += REVIZYON_EK_SURE_MS;
|
|
||||||
zamanlayici = setTimeout(() => kesici.abort(), sure);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(ad) => {
|
|
||||||
adimdakiArac++;
|
adimdakiArac++;
|
||||||
toplamArac++;
|
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;
|
if (adimdakiArac <= ADIM_BASINA_ARAC && toplamArac <= CEVAP_BASINA_ARAC) return null;
|
||||||
reddedilenArac++;
|
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.`;
|
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) => {
|
(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)
|
// Başarılı kayıt = profil_guncelle "kaydedildi" döndü (hata dönerse sayılmaz)
|
||||||
if (ad === "profil_guncelle" && nesne && "kaydedildi" in nesne) basariliKayit++;
|
if (ad === "profil_guncelle" && cikti != null && typeof cikti === "object" && "kaydedildi" in cikti) {
|
||||||
if (ad === "liste_revize" && nesne?.ok === true) basariliRevizyon++;
|
basariliKayit++;
|
||||||
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
|
// En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
|
||||||
@@ -153,8 +109,7 @@ export function danismanAkisi(opts: {
|
|||||||
stopWhen: isStepCount(ARAC_TUR_SINIRI),
|
stopWhen: isStepCount(ARAC_TUR_SINIRI),
|
||||||
prepareStep: ({ stepNumber }) => {
|
prepareStep: ({ stepNumber }) => {
|
||||||
adimdakiArac = 0;
|
adimdakiArac = 0;
|
||||||
// Form açıldıysa model cevabı bitirmek zorunda (araç kapalı)
|
return stepNumber >= ARAC_TUR_SINIRI - 1 ? { toolChoice: "none" } : undefined;
|
||||||
return stepNumber >= ARAC_TUR_SINIRI - 1 || formAcildi ? { toolChoice: "none" } : undefined;
|
|
||||||
},
|
},
|
||||||
maxOutputTokens: 1024,
|
maxOutputTokens: 1024,
|
||||||
abortSignal: zamanAsimi,
|
abortSignal: zamanAsimi,
|
||||||
@@ -229,22 +184,11 @@ export function danismanAkisi(opts: {
|
|||||||
writer.write({ type: "text-delta", id, delta: KAYIT_DUZELTME_NOTU });
|
writer.write({ type: "text-delta", id, delta: KAYIT_DUZELTME_NOTU });
|
||||||
writer.write({ type: "text-end", id });
|
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.
|
// İç şema sızıntısı: yalnız gözlem (cevap geri alınamaz). Metin loglanmaz.
|
||||||
const semaSizintisi = semaSizintisiVar(metin, ARAC_ADLARI);
|
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 (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ı");
|
if (reddedilenArac) console.info("[soru] araç sınırı:", reddedilenArac, "çağrı çalıştırılmadı");
|
||||||
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, revizeNotu, semaSizintisi });
|
opts.onDenetim?.({ jevP, vaatRegex, vaatNotu, reddedilenArac, kayitNotu, semaSizintisi });
|
||||||
await opts.onTamam(metin);
|
await opts.onTamam(metin);
|
||||||
tamamlandi = true;
|
tamamlandi = true;
|
||||||
writer.write({ type: "finish" });
|
writer.write({ type: "finish" });
|
||||||
@@ -255,8 +199,6 @@ export function danismanAkisi(opts: {
|
|||||||
// Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa
|
// 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.
|
// (boş cevap, kayıt hatası) istemci hatayı buradan görür.
|
||||||
if (akisHatasi == null) throw err;
|
if (akisHatasi == null) throw err;
|
||||||
} finally {
|
|
||||||
clearTimeout(zamanlayici);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => HATA_METNI,
|
onError: () => HATA_METNI,
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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")}]`;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { SohbetMeta } from "@/lib/ai/sohbet-meta";
|
|
||||||
import {
|
import {
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
@@ -146,9 +145,6 @@ export const chatMessages = sqliteTable(
|
|||||||
role: text("role", { enum: ["user", "assistant"] }).notNull(),
|
role: text("role", { enum: ["user", "assistant"] }).notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
clientMessageId: text("client_message_id").unique(),
|
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(),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],
|
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { and, eq, gte, isNull, sql } from "drizzle-orm";
|
import { and, eq, gte, isNull, sql } from "drizzle-orm";
|
||||||
import { appDb, schema } from "./appdb";
|
import { appDb, schema } from "./appdb";
|
||||||
import type { SohbetMeta } from "./ai/sohbet-meta";
|
|
||||||
|
|
||||||
const { user, creditLedger, chatMessages } = schema;
|
const { user, creditLedger, chatMessages } = schema;
|
||||||
|
|
||||||
@@ -100,7 +99,6 @@ export async function spendCreditForMessage(opts: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
content: string;
|
content: string;
|
||||||
clientMessageId: string;
|
clientMessageId: string;
|
||||||
meta?: SohbetMeta;
|
|
||||||
}): Promise<SpendResult & { messageId?: string }> {
|
}): Promise<SpendResult & { messageId?: string }> {
|
||||||
const messageId = newId();
|
const messageId = newId();
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +117,6 @@ export async function spendCreditForMessage(opts: {
|
|||||||
role: "user",
|
role: "user",
|
||||||
content: opts.content,
|
content: opts.content,
|
||||||
clientMessageId: opts.clientMessageId,
|
clientMessageId: opts.clientMessageId,
|
||||||
meta: opts.meta ?? null,
|
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
await tx.insert(creditLedger).values({
|
await tx.insert(creditLedger).values({
|
||||||
@@ -141,16 +138,14 @@ export async function spendCreditForMessage(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — profil onayı ("kaydedeyim mi?"
|
* Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — yalnız profil onayı ("kaydedeyim
|
||||||
* → "evet"; Bilal kararı, 26 Eyl 2026: onay ücretsiz) ve danışmanın soru
|
* mi?" → "evet") için (Bilal kararı, 26 Eyl 2026: onay ücretsiz). Ledger'a
|
||||||
* formuna verilen cevap (7 Eki 2026) için. Ledger'a satır yazılmaz;
|
* satır yazılmaz; clientMessageId çakışırsa DUPLICATE.
|
||||||
* clientMessageId çakışırsa DUPLICATE.
|
|
||||||
*/
|
*/
|
||||||
export async function bedelsizMesajKaydet(opts: {
|
export async function bedelsizMesajKaydet(opts: {
|
||||||
userId: string;
|
userId: string;
|
||||||
content: string;
|
content: string;
|
||||||
clientMessageId: string;
|
clientMessageId: string;
|
||||||
meta?: SohbetMeta;
|
|
||||||
}): Promise<SpendResult & { messageId?: string }> {
|
}): Promise<SpendResult & { messageId?: string }> {
|
||||||
const messageId = newId();
|
const messageId = newId();
|
||||||
try {
|
try {
|
||||||
@@ -160,7 +155,6 @@ export async function bedelsizMesajKaydet(opts: {
|
|||||||
role: "user",
|
role: "user",
|
||||||
content: opts.content,
|
content: opts.content,
|
||||||
clientMessageId: opts.clientMessageId,
|
clientMessageId: opts.clientMessageId,
|
||||||
meta: opts.meta ?? null,
|
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
return { ok: true, messageId };
|
return { ok: true, messageId };
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
@@ -177,13 +177,6 @@ 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:
|
* 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
|
* gevşeyen filtreye UYAN programlar önce gelir ve kırpmada korunur, kalan
|
||||||
@@ -278,24 +271,9 @@ function dilimEkle(r: {
|
|||||||
* Adayın yazamayacağı programlar hiçbir basamakta havuza girmez: başarı
|
* 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ı.
|
* 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(
|
export function havuzOlustur(
|
||||||
params: RaporParams,
|
params: RaporParams,
|
||||||
profil?: DanismanProfili,
|
profil?: DanismanProfili,
|
||||||
secenekler: HavuzSecenekleri = {},
|
|
||||||
): HavuzSonuc {
|
): HavuzSonuc {
|
||||||
const uniturGrubu =
|
const uniturGrubu =
|
||||||
params.universiteTipi && params.universiteTipi !== "farketmez"
|
params.universiteTipi && params.universiteTipi !== "farketmez"
|
||||||
@@ -329,39 +307,7 @@ export function havuzOlustur(
|
|||||||
const adaylar = (ozellik?: OzellikSuzgeci) =>
|
const adaylar = (ozellik?: OzellikSuzgeci) =>
|
||||||
dilimEkle(
|
dilimEkle(
|
||||||
havuzAdaylari(params.sira, params.tur, { uniturGrubu, ozellik }),
|
havuzAdaylari(params.sira, params.tur, { uniturGrubu, ozellik }),
|
||||||
).filter(
|
).filter((p) => !ozelKontenjanSatiriMi(p.isim));
|
||||||
(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[] = [];
|
const profilGevsetildi: Gevsetilen[] = [];
|
||||||
// Profil gevşerse profile uyan (24'ten az) programlar yine en önde tutulur
|
// Profil gevşerse profile uyan (24'ten az) programlar yine en önde tutulur
|
||||||
@@ -374,17 +320,22 @@ export function havuzOlustur(
|
|||||||
profilliFiltreli = kategoriFiltre(profilliHam);
|
profilliFiltreli = kategoriFiltre(profilliHam);
|
||||||
if (profilliFiltreli.length >= HEDEF) {
|
if (profilliFiltreli.length >= HEDEF) {
|
||||||
return {
|
return {
|
||||||
havuz: kirpVe(profilliFiltreli),
|
havuz: dilimBasinaKirp(uyumlaSirala(profilliFiltreli, baglam), PROMPT_LIMIT_DILIM),
|
||||||
gevsetildi: [],
|
gevsetildi: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
profilGevsetildi.push("profil");
|
profilGevsetildi.push("profil");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Tam filtre: üniversite tipi (SQL), iller + kategori (JS)
|
||||||
|
const ilsizHam = adaylar();
|
||||||
const tamHam = ilFiltre(ilsizHam, iller);
|
const tamHam = ilFiltre(ilsizHam, iller);
|
||||||
const tamFiltreli = kategoriFiltre(tamHam);
|
const tamFiltreli = kategoriFiltre(tamHam);
|
||||||
const kirp = (...katmanlar: AdayProgram[][]) =>
|
const kirp = (...katmanlar: AdayProgram[][]) =>
|
||||||
kirpVe(oncelikliBirlestir(profilliFiltreli, ...katmanlar));
|
dilimBasinaKirp(
|
||||||
|
uyumlaSirala(oncelikliBirlestir(profilliFiltreli, ...katmanlar), baglam),
|
||||||
|
PROMPT_LIMIT_DILIM,
|
||||||
|
);
|
||||||
if (tamFiltreli.length >= HEDEF) {
|
if (tamFiltreli.length >= HEDEF) {
|
||||||
return { havuz: kirp(tamFiltreli), gevsetildi: profilGevsetildi };
|
return { havuz: kirp(tamFiltreli), gevsetildi: profilGevsetildi };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user